diff --git a/src/components/Composer/FeedsSelector/index.tsx b/src/components/Composer/FeedsSelector/index.tsx new file mode 100644 index 0000000000..6872185a75 --- /dev/null +++ b/src/components/Composer/FeedsSelector/index.tsx @@ -0,0 +1,7 @@ +export function FeedsSelector(_props: { + max?: number + initialFeeds?: string[] + onChangeFeeds: (uris: string[]) => void +}) { + return null +} diff --git a/src/components/Composer/FeedsSelector/index.web.tsx b/src/components/Composer/FeedsSelector/index.web.tsx new file mode 100644 index 0000000000..2d2887bf65 --- /dev/null +++ b/src/components/Composer/FeedsSelector/index.web.tsx @@ -0,0 +1,351 @@ +import React from 'react' +import { + NativeSyntheticEvent, + Platform, + Pressable, + StyleSheet, + TextInput, + TextInputKeyPressEventData, + View, +} from 'react-native' +import {TextInputFocusEventData} from 'react-native' +import {AppBskyFeedDefs} from '@atproto/api' +import {Pin} from 'pind' + +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonProps} from '#/components/Button' +import {useFeedsAutocomplete} from '#/components/Composer/FeedsSelector/useFeedsAutocomplete' +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 FeedCard({ + feed, + onPress, +}: { + feed: AppBskyFeedDefs.GeneratorView + onPress: ButtonProps['onPress'] +}) { + const t = useTheme() + return ( + + ) +} + +export function FeedsSelector({ + max = 8, + initialFeeds = [], + onChangeFeeds, +}: { + max?: number + initialFeeds?: string[] + onChangeFeeds: (uris: string[]) => void +}) { + const t = useTheme() + const dropdown = React.useRef(null) + const input = React.useRef(null) + const inputWidth = input.current + ? input.current.getBoundingClientRect().width + : 200 + const {query, suggestions, feeds, setQuery} = useFeedsAutocomplete() + const containerRef = React.useRef(null) + + const [uris, setUris] = React.useState(initialFeeds) + const [selectedItemIndex, setSelectedItemIndex] = React.useState(0) + + const dropdownIsActive = Boolean(query.length) + + const closeDropdownAndReset = React.useCallback(() => { + setQuery('') + setSelectedItemIndex(0) + }, [setQuery, setSelectedItemIndex]) + + const addFeeds = React.useCallback( + (feedUris: string[]) => { + const uris = feedUris.slice(0, max) + setUris(uris) + onChangeFeeds(uris) + }, + [onChangeFeeds, setUris, max], + ) + + const removeFeed = React.useCallback( + (uri: string) => { + addFeeds(uris.filter(t => t !== uri)) + }, + [uris, addFeeds], + ) + + const addFeedAndReset = React.useCallback( + (uri: string) => { + addFeeds(Array.from(new Set([...uris, uri])).slice(0, max)) + setQuery('') + input.current?.focus() + closeDropdownAndReset() + }, + [max, uris, closeDropdownAndReset, setQuery, addFeeds], + ) + + const onSubmitEditing = React.useCallback(() => { + const item = suggestions[selectedItemIndex] + addFeedAndReset(item?.uri) + }, [suggestions, selectedItemIndex, addFeedAndReset]) + + const onKeyPress = React.useCallback( + (e: NativeSyntheticEvent) => { + const {key} = e.nativeEvent + + if (key === 'Backspace' && query === '') { + addFeeds(uris.slice(0, -1)) + closeDropdownAndReset() + } + + if (dropdownIsActive) { + if (key === 'Escape') { + closeDropdownAndReset() + } else if (key === 'ArrowUp') { + e.preventDefault() + setSelectedItemIndex( + (selectedItemIndex + suggestions.length - 1) % suggestions.length, + ) + } else if (key === 'ArrowDown') { + e.preventDefault() + setSelectedItemIndex((selectedItemIndex + 1) % suggestions.length) + } + } + }, + [ + query, + uris, + dropdownIsActive, + selectedItemIndex, + suggestions.length, + closeDropdownAndReset, + setSelectedItemIndex, + addFeeds, + ], + ) + + const onChangeText = React.useCallback( + async (value: string) => { + const tag = sanitizeHashtagOnChange(value) + + setQuery(tag) + + if (tag.length) { + setQuery(tag) + } else { + setSelectedItemIndex(0) + } + }, + [setSelectedItemIndex, setQuery], + ) + + const onBlur = React.useCallback( + (e: NativeSyntheticEvent) => { + // @ts-ignore + const target = e.nativeEvent.relatedTarget as HTMLElement | undefined + + if ( + !uris.length && + (!target || !target.id.includes('tag_autocomplete_option')) + ) { + setQuery('') + } + }, + [uris, setQuery], + ) + + 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]) + + const feedViews = uris + .map(uri => feeds.find(f => f.uri === uri)) + .filter(Boolean) as AppBskyFeedDefs.GeneratorView[] + + return ( + + + {feedViews.map(feed => ( + removeFeed(feed.uri)} + feed={feed} + /> + ))} + + {uris.length >= max ? null : ( + + )} + + + + + {suggestions.map((item, index) => { + const isFirst = index === 0 + const isLast = index === suggestions.length - 1 + return ( + addFeedAndReset(item.uri)} + 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.displayName} + + ) + })} + + + + ) +} + +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/components/Composer/FeedsSelector/useFeedsAutocomplete.ts b/src/components/Composer/FeedsSelector/useFeedsAutocomplete.ts new file mode 100644 index 0000000000..c35376687a --- /dev/null +++ b/src/components/Composer/FeedsSelector/useFeedsAutocomplete.ts @@ -0,0 +1,39 @@ +import React from 'react' +import Fuse from 'fuse.js' + +import {useProfileFeedgensQuery} from '#/state/queries/profile-feedgens' +import {useSession} from '#/state/session' + +export function useFeedsAutocomplete() { + const {currentAccount} = useSession() + const [query, setQuery] = React.useState('') + + const {data} = useProfileFeedgensQuery(currentAccount!.did) + const allFeeds = React.useMemo(() => { + return data?.pages?.flatMap(page => page.feeds) || [] + }, [data]) + + const onSetQuery = React.useCallback( + (query: string) => { + setQuery(query) + }, + [setQuery], + ) + + const suggestions = React.useMemo(() => { + const items = allFeeds + const fuse = new Fuse(items, { + keys: ['displayName', 'description'], + }) + // search amongst mixed set of tags + const results = fuse.search(query).map(r => r.item) + return results + }, [query, allFeeds]) + + return { + query, + feeds: allFeeds, + suggestions, + setQuery: onSetQuery, + } +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 9e9c14c3d5..dc887ef7ac 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -105,11 +105,13 @@ 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 {FeedsSelector} from '#/components/Composer/FeedsSelector' import {OutlineTags} from '#/components/Composer/OutlineTags' import {Divider} from '#/components/Divider' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' +import {ListSparkle_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/ListSparkle' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import * as Prompt from '#/components/Prompt' import {Text as NewText} from '#/components/Typography' @@ -524,8 +526,13 @@ export const ComposePost = ({ [dispatch], ) + const onChangeFeeds = useCallback((uris: string[]) => { + console.log(uris) + }, []) + const [showOutlineTags, setShowOutlineTags] = useState(false) const outlineTagsVisible = showOutlineTags || draft.tags.length > 0 + const [showFeeds, setShowFeeds] = useState(false) const { scrollHandler, @@ -766,7 +773,7 @@ export const ComposePost = ({ {outlineTagsVisible && ( - + )} + {showFeeds && ( + + + + + + + + + + + + )} + )} + + {!showFeeds && ( + + )}