diff --git a/src/state/models/ui/tags-autocomplete.ts b/src/state/models/ui/tags-autocomplete.ts index ef7a051d99..9b1d8b43c5 100644 --- a/src/state/models/ui/tags-autocomplete.ts +++ b/src/state/models/ui/tags-autocomplete.ts @@ -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() diff --git a/src/view/com/Tag.tsx b/src/view/com/Tag.tsx index 9620f4be16..f187c8a745 100644 --- a/src/view/com/Tag.tsx +++ b/src/view/com/Tag.tsx @@ -81,6 +81,59 @@ export function EditableTag({ ) } +export function TagButton({ + value, + icon = 'x', + onClick, +}: { + value: string + icon?: React.ComponentProps['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 ( + onClick?.(value)} + onPointerEnter={hoverIn} + onPointerLeave={hoverOut} + style={state => [ + pal.viewLight, + styles.editableTag, + { + opacity: state.pressed || state.focused ? 0.8 : 1, + outline: 0, + paddingRight: 6, + }, + ]}> + + #{value} + + + + ) +} + const styles = StyleSheet.create({ editableTag: { flexDirection: 'row', diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 14b2d94db9..8436a6a2c5 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -446,18 +446,13 @@ export const ComposePost = observer(function ComposePost({ - + diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index 4678d4730e..65411584d1 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -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(null) const pal = usePalette('default') - const input = React.useRef(null) + const input = React.useRef(null) + const [value, setValue] = React.useState('') const [tags, setTags] = React.useState([]) + const [selectedItemIndex, setSelectedItemIndex] = React.useState(0) + const [suggestions, setSuggestions] = React.useState([]) + 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) => { - 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 ( - - {!tags.length && ( - - )} - {tags.map(tag => ( - - ))} - {tags.length >= max ? null : ( - - )} + + { + sheet.current?.snapToIndex(0) + }}> + + {tags.length ? ( + + Add + + + ) : ( + <> + + + Click to add tags to your post + + + )} + + + {tags.map(tag => ( + + + #{tag} + + + ))} + + + + ( + + )} + handleIndicatorStyle={{backgroundColor: pal.text.color}} + handleStyle={{display: 'none'}} + onChange={onSheetChange} + onClose={onCloseSheet}> + + + + + + + {tags.map(tag => ( + + ))} + + + + + + + + {isInitialLoad && } + + {suggestions + .filter(s => !tags.find(t => t === s)) + .map(suggestion => { + return ( + + ) + })} + + + + + + ) } 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, + }, }) diff --git a/src/view/com/sheets/Base.tsx b/src/view/com/sheets/Base.tsx new file mode 100644 index 0000000000..75dd8f62ba --- /dev/null +++ b/src/view/com/sheets/Base.tsx @@ -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 ( + <> + + + {props.children} + + ) +} + +export function Handle() { + const pal = usePalette('default') + return ( + + ) +} + +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, + }, +}) diff --git a/src/view/com/util/Portal.tsx b/src/view/com/util/Portal.tsx new file mode 100644 index 0000000000..1813d9e05e --- /dev/null +++ b/src/view/com/util/Portal.tsx @@ -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({ + outlet: null, + append: () => {}, + remove: () => {}, +}) + +export function Provider(props: React.PropsWithChildren<{}>) { + const map = React.useRef({}) + const [outlet, setOutlet] = React.useState(null) + + const append = React.useCallback((id, component) => { + if (map.current[id]) return + map.current[id] = {component} + setOutlet(<>{Object.values(map.current)}) + }, []) + + const remove = React.useCallback(id => { + delete map.current[id] + setOutlet(<>{Object.values(map.current)}) + }, []) + + return ( + + {props.children} + + ) +} + +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 +} diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 3119715e94..a4f1cab0c8 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -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() { /> + ) }) @@ -88,12 +90,16 @@ export const Shell: React.FC = observer(function ShellImpl() { const theme = useTheme() return ( - - - - - - + + + + + + + + ) })