diff --git a/package.json b/package.json index 6974482844..de2e861da5 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "mobx-utils": "^6.0.6", "normalize-url": "^8.0.0", "patch-package": "^6.5.1", + "pind": "^0.5.0", "postinstall-postinstall": "^2.1.0", "psl": "^1.9.0", "react": "18.2.0", diff --git a/src/state/models/ui/tags-autocomplete.ts b/src/state/models/ui/tags-autocomplete.ts index ed9f3c7156..ef7a051d99 100644 --- a/src/state/models/ui/tags-autocomplete.ts +++ b/src/state/models/ui/tags-autocomplete.ts @@ -109,7 +109,15 @@ export class TagsAutocompleteModel { // TODO hook up to search type-ahead async _search() { runInAction(() => { - this.searchedTags = [] + this.searchedTags = [ + 'bluesky', + 'code', + 'coding', + 'dev', + 'developer', + 'development', + 'devlife', + ] }) } } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 44504d7e5f..14b2d94db9 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -454,7 +454,10 @@ export const ComposePost = observer(function ComposePost({ paddingHorizontal: 15, }, ]}> - + diff --git a/src/view/com/composer/TagInput.tsx b/src/view/com/composer/TagInput.tsx index d098be0af2..4678d4730e 100644 --- a/src/view/com/composer/TagInput.tsx +++ b/src/view/com/composer/TagInput.tsx @@ -12,6 +12,7 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +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' @@ -40,6 +41,7 @@ export function TagInput({ }: { max?: number onChangeTags: (tags: string[]) => void + tagsAutocompleteModel: TagsAutocompleteModel }) { const pal = usePalette('default') const input = React.useRef(null) diff --git a/src/view/com/composer/TagInput.web.tsx b/src/view/com/composer/TagInput.web.tsx new file mode 100644 index 0000000000..6e384bedeb --- /dev/null +++ b/src/view/com/composer/TagInput.web.tsx @@ -0,0 +1,311 @@ +import React from 'react' +import { + TextInput, + View, + StyleSheet, + NativeSyntheticEvent, + TextInputKeyPressEventData, + Platform, + Pressable, +} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {Pin} from 'pind' + +import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete' +import {usePalette} from 'lib/hooks/usePalette' +import {EditableTag} from 'view/com/Tag' +import {Text} from 'view/com/util/text/Text' + +function uniq(tags: string[]) { + return Array.from(new Set(tags)) +} + +function sanitize(tagString: string) { + return tagString.trim().replace(/^#/, '') +} + +export function TagInput({ + max = 8, + onChangeTags, + tagsAutocompleteModel: model, +}: { + max?: number + onChangeTags: (tags: string[]) => void + tagsAutocompleteModel: TagsAutocompleteModel +}) { + const pal = usePalette('default') + const dropdown = React.useRef(null) + const input = React.useRef(null) + const inputWidth = input.current + ? input.current.getBoundingClientRect().width + : 200 + + const [value, setValue] = React.useState('') + const [tags, setTags] = React.useState([]) + const [dropdownIsOpen, setDropdownIsOpen] = React.useState(false) + const [dropdownItems, setDropdownItems] = React.useState< + {value: string; label: string}[] + >([]) + const [selectedItemIndex, setSelectedItemIndex] = React.useState(0) + + const close = React.useCallback(() => { + setDropdownIsOpen(false) + model.setActive(false) + setSelectedItemIndex(0) + setDropdownItems([]) + }, [model, setDropdownIsOpen, setSelectedItemIndex, setDropdownItems]) + + const addTags = React.useCallback( + (_tags: string[]) => { + setTags(_tags) + onChangeTags(_tags) + }, + [onChangeTags, setTags], + ) + + const removeTag = React.useCallback( + (tag: string) => { + addTags(tags.filter(t => t !== tag)) + }, + [tags, addTags], + ) + + 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)) + } + + setValue('') + input.current?.focus() + close() + }, + [max, tags, close, setValue, addTags], + ) + + const onSubmitEditing = React.useCallback(() => { + const item = dropdownItems[selectedItemIndex] + addTagAndReset(item?.value || value) + }, [value, dropdownItems, selectedItemIndex, addTagAndReset]) + + const onKeyPress = React.useCallback( + (e: NativeSyntheticEvent) => { + const {key} = e.nativeEvent + + if (key === 'Backspace' && value === '') { + addTags(tags.slice(0, -1)) + } else if (key === ' ') { + e.preventDefault() // prevents an additional space on web + addTagAndReset(value) + } + + if (dropdownIsOpen) { + if (key === 'Escape') { + close() + } else if (key === 'ArrowUp') { + e.preventDefault() + setSelectedItemIndex( + (selectedItemIndex + dropdownItems.length - 1) % + dropdownItems.length, + ) + } else if (key === 'ArrowDown') { + e.preventDefault() + setSelectedItemIndex((selectedItemIndex + 1) % dropdownItems.length) + } + } + }, + [ + value, + tags, + dropdownIsOpen, + selectedItemIndex, + dropdownItems.length, + close, + setSelectedItemIndex, + addTags, + addTagAndReset, + ], + ) + + const onChangeText = React.useCallback( + async (v: string) => { + setValue(v) + + if (v.length > 0) { + model.setActive(true) + await model.search(v) + + setDropdownItems( + model.suggestions.map(item => ({ + value: item, + label: item, + })), + ) + + setDropdownIsOpen(true) + } else { + close() + } + }, + [model, setValue, setDropdownIsOpen, close], + ) + + React.useEffect(() => { + // outside click + function onClick(e: MouseEvent) { + const drop = dropdown.current + const control = input.current + + if ( + !drop || + !control || + e.target === drop || + e.target === control || + drop.contains(e.target as Node) || + control.contains(e.target as Node) + ) + return + + close() + } + + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + } + }, [close]) + + return ( + + {!tags.length && ( + + )} + + {tags.map(tag => ( + + ))} + + {tags.length >= max ? null : ( + + )} + + + + {dropdownItems.map((item, index) => { + const isFirst = index === 0 + const isLast = index === dropdownItems.length - 1 + return ( + addTagAndReset(item.value)} + style={state => [ + pal.border, + styles.dropdownItem, + { + backgroundColor: state.hovered + ? pal.viewLight.backgroundColor + : undefined, + }, + selectedItemIndex === index ? pal.viewLight : undefined, + isFirst + ? styles.firstResult + : isLast + ? styles.lastResult + : undefined, + ]}> + + {item.label} + + + ) + })} + + + + ) +} + +const styles = StyleSheet.create({ + outer: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + }, + input: { + flexGrow: 1, + minWidth: 100, + fontSize: 15, + lineHeight: Platform.select({ + web: 20, + native: 18, + }), + paddingTop: 4, + paddingBottom: 4, + }, + dropdown: { + width: '100%', + borderRadius: 6, + borderWidth: 1, + borderStyle: 'solid', + padding: 4, + }, + dropdownItem: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexDirection: 'row', + paddingHorizontal: 12, + paddingVertical: 8, + gap: 4, + }, + firstResult: { + borderTopLeftRadius: 2, + borderTopRightRadius: 2, + }, + lastResult: { + borderBottomLeftRadius: 2, + borderBottomRightRadius: 2, + }, +}) diff --git a/yarn.lock b/yarn.lock index 58b1d2f3db..1685200e13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14421,6 +14421,13 @@ pify@^4.0.1: resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== +pind@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/pind/-/pind-0.5.0.tgz#9f04be810b0c91aac0bb0976a57ba1d76126d0c8" + integrity sha512-xK7GlJl2AeQQaYbtxoGOsGVa0Ms8M4B3CPIIohC2gmeDBRrxtBSWyzPc6cgdG4AEEvQETt//bIBETPy8VFO+BA== + dependencies: + tackjs "^3.0.1" + pinkie-promise@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" @@ -17517,6 +17524,11 @@ symbol-tree@^3.2.4: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== +tackjs@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/tackjs/-/tackjs-3.0.1.tgz#d69af48df5921320953d28131a8ae110ae11ea06" + integrity sha512-a/0vc4RWqG/9mYeldQ9uhGGTicc4LCr0EPpjV0IIKB21QkmFfkN559wu/Z4rprOzWDnEjUzdwsqIIrQC1H+rpg== + tailwindcss@^3.0.2: version "3.3.3" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.3.3.tgz#90da807393a2859189e48e9e7000e6880a736daf"