diff --git a/package.json b/package.json index 607bbcc130..0143c1d78c 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,7 @@ "nanoid": "^5.0.5", "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/components/Composer/OutlineTags/index.tsx b/src/components/Composer/OutlineTags/index.tsx new file mode 100644 index 0000000000..a4d18c61f5 --- /dev/null +++ b/src/components/Composer/OutlineTags/index.tsx @@ -0,0 +1,7 @@ +export function OutlineTags(_props: { + max?: number + initialTags?: string[] + onChangeTags: (tags: string[]) => void +}) { + return null +} diff --git a/src/components/Composer/OutlineTags/index.web.tsx b/src/components/Composer/OutlineTags/index.web.tsx new file mode 100644 index 0000000000..2181e3c255 --- /dev/null +++ b/src/components/Composer/OutlineTags/index.web.tsx @@ -0,0 +1,374 @@ +import React from 'react' +import { + NativeSyntheticEvent, + Platform, + Pressable, + StyleSheet, + TextInput, + TextInputKeyPressEventData, + View, +} from 'react-native' +import {TextInputFocusEventData} from 'react-native' +import {Pin} from 'pind' + +import {isWeb} from '#/platform/detection' +import {useSession} from '#/state/session' +import {tagAutocompleteModel} from '#/view/com/composer/text-input/tagsAutocompleteState' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonProps,ButtonText} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Text} from '#/components/Typography' + +/** + * Basically `sanitizeHashtag` from `@atproto/api`, but ignores trailing + * punctuation in case the user intends to use `_` or `-`. + */ +export function sanitizeHashtagOnChange(hashtag: string) { + return hashtag.replace(/^\d+/g, '').slice(0, 64) +} + +function TagButton({ + children, + onPress, +}: { + children: string + onPress: ButtonProps['onPress'] +}) { + return ( + + ) +} + +export function OutlineTags({ + max = 8, + initialTags = [], + onChangeTags, +}: { + max?: number + initialTags?: string[] + onChangeTags: (tags: string[]) => void +}) { + const t = useTheme() + const {currentAccount} = useSession() + const dropdown = React.useRef(null) + const input = React.useRef(null) + const inputWidth = input.current + ? input.current.getBoundingClientRect().width + : 200 + const model = React.useMemo( + () => tagAutocompleteModel({currentDid: currentAccount?.did!}), + [currentAccount], + ) + const containerRef = React.useRef(null) + + const [value, setValue] = React.useState('') + const [tags, setTags] = React.useState(initialTags) + const [dropdownItems, setDropdownItems] = React.useState< + {value: string; label: string}[] + >([]) + const [selectedItemIndex, setSelectedItemIndex] = React.useState(0) + + const dropdownIsActive = Boolean(value.length) + + const closeDropdownAndReset = React.useCallback(() => { + setValue('') + setSelectedItemIndex(0) + setDropdownItems([]) + }, [setSelectedItemIndex, setDropdownItems]) + + const addTags = React.useCallback( + (_tags: string[]) => { + const _t = _tags.slice(0, max) + setTags(_t) + onChangeTags(_t) + }, + [onChangeTags, setTags, max], + ) + + const removeTag = React.useCallback( + (tag: string) => { + addTags(tags.filter(t => t !== tag)) + }, + [tags, addTags], + ) + + const addTagAndReset = React.useCallback( + (value: string) => { + const tag = sanitizeHashtagOnChange(value).replace(/^#{1}/, '') + + // enforce max hashtag length + if (tag.length > 0 && tag.length <= 64) { + addTags(Array.from(new Set([...tags, tag])).slice(0, max)) + } + + model.save(tag) + setValue('') + input.current?.focus() + closeDropdownAndReset() + }, + [max, tags, closeDropdownAndReset, setValue, addTags, model], + ) + + 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)) + closeDropdownAndReset() + } else if (key === ' ') { + e.preventDefault() // prevents an additional space on web + addTagAndReset(value) + } + + if (dropdownIsActive) { + if (key === 'Escape') { + closeDropdownAndReset() + } else if (key === 'ArrowUp') { + e.preventDefault() + setSelectedItemIndex( + (selectedItemIndex + dropdownItems.length - 1) % + dropdownItems.length, + ) + } else if (key === 'ArrowDown') { + e.preventDefault() + setSelectedItemIndex((selectedItemIndex + 1) % dropdownItems.length) + } else if ( + isWeb && + key === 'Tab' && + // @ts-ignore web only + !e.nativeEvent.shiftKey + ) { + e.preventDefault() + onSubmitEditing() + } + } + }, + [ + value, + tags, + dropdownIsActive, + selectedItemIndex, + dropdownItems.length, + closeDropdownAndReset, + setSelectedItemIndex, + addTags, + addTagAndReset, + onSubmitEditing, + ], + ) + + const search = React.useCallback( + async (value: string) => { + await model.search(value) + setDropdownItems( + model.suggestions.map(s => ({ + value: s.value, + label: s.value, + })), + ) + }, + [model, setDropdownItems], + ) + + const onChangeText = React.useCallback( + async (value: string) => { + const tag = sanitizeHashtagOnChange(value) + + setValue(tag) + + if (tag.length) { + search(tag) + } else { + closeDropdownAndReset() + } + }, + [setValue, closeDropdownAndReset, search], + ) + + const onBlur = React.useCallback( + (e: NativeSyntheticEvent) => { + // @ts-ignore + const target = e.nativeEvent.relatedTarget as HTMLElement | undefined + + if ( + !tags.length && + (!target || !target.id.includes('tag_autocomplete_option')) + ) { + setValue('') + } + }, + [tags, setValue], + ) + + 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 + + closeDropdownAndReset() + } + + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + } + }, [closeDropdownAndReset]) + + return ( + + + {tags.map((tag, i) => ( + removeTag(tag)}> + {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 => [ + t.atoms.border_contrast_low, + styles.dropdownItem, + { + backgroundColor: state.hovered + ? t.atoms.bg_contrast_25.backgroundColor + : undefined, + }, + selectedItemIndex === index + ? t.atoms.bg_contrast_25 + : 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: 5, + paddingBottom: 5, + }, + 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/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 126addd1c8..79e9acfbe0 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -105,6 +105,7 @@ import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {OutlineTags} from '#/components/Composer/OutlineTags' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' @@ -744,6 +745,8 @@ export const ComposePost = ({ + {}} /> +