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"