FeedsSelector
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
export function FeedsSelector(_props: {
|
||||||
|
max?: number
|
||||||
|
initialFeeds?: string[]
|
||||||
|
onChangeFeeds: (uris: string[]) => void
|
||||||
|
}) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Button
|
||||||
|
label="Remove tag"
|
||||||
|
size="tiny"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
onPress={onPress}
|
||||||
|
style={[a.py_sm, a.pl_sm, a.pr_md, a.rounded_sm, a.gap_sm]}>
|
||||||
|
<UserAvatar avatar="" size={32} type="algo" />
|
||||||
|
<View style={[a.flex_1, a.gap_2xs, a.pr_sm]}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_sm, a.font_bold, a.leading_tight]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{feed.displayName}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[a.text_xs, a.leading_tight, t.atoms.text_contrast_medium]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
By @{feed.creator.handle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ButtonIcon icon={X} position="right" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedsSelector({
|
||||||
|
max = 8,
|
||||||
|
initialFeeds = [],
|
||||||
|
onChangeFeeds,
|
||||||
|
}: {
|
||||||
|
max?: number
|
||||||
|
initialFeeds?: string[]
|
||||||
|
onChangeFeeds: (uris: string[]) => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const dropdown = React.useRef<HTMLDivElement>(null)
|
||||||
|
const input = React.useRef<HTMLInputElement>(null)
|
||||||
|
const inputWidth = input.current
|
||||||
|
? input.current.getBoundingClientRect().width
|
||||||
|
: 200
|
||||||
|
const {query, suggestions, feeds, setQuery} = useFeedsAutocomplete()
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [uris, setUris] = React.useState<string[]>(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<TextInputKeyPressEventData>) => {
|
||||||
|
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<TextInputFocusEventData>) => {
|
||||||
|
// @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 (
|
||||||
|
<View ref={containerRef as any} style={[a.w_full]}>
|
||||||
|
<View style={[a.flex_row, a.align_start, a.flex_wrap, a.gap_sm]}>
|
||||||
|
{feedViews.map(feed => (
|
||||||
|
<FeedCard
|
||||||
|
key={feed.uri}
|
||||||
|
onPress={() => removeFeed(feed.uri)}
|
||||||
|
feed={feed}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{uris.length >= max ? null : (
|
||||||
|
<TextInput
|
||||||
|
ref={input as any}
|
||||||
|
id="tags-autocomplete-input"
|
||||||
|
role={'listbox' as any}
|
||||||
|
aria-controls="tags-autocomplete-dropdown"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={dropdownIsActive}
|
||||||
|
value={query}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onKeyPress={onKeyPress}
|
||||||
|
onSubmitEditing={onSubmitEditing}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
blurOnSubmit={false}
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.leading_tight,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
{
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
placeholder="Add feeds"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="off"
|
||||||
|
accessible={true}
|
||||||
|
accessibilityLabel="Add feeds to your post"
|
||||||
|
accessibilityHint={`Type a tag and press enter to add it. You can add up to ${max} tag.`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pin
|
||||||
|
pinned={Boolean(query.length)}
|
||||||
|
to={input}
|
||||||
|
at="bottomLeft"
|
||||||
|
from="topLeft"
|
||||||
|
style={{width: inputWidth}}>
|
||||||
|
<View
|
||||||
|
ref={dropdown as any}
|
||||||
|
style={[t.atoms.bg, t.atoms.border_contrast_low, styles.dropdown]}
|
||||||
|
role={'listbox' as any}
|
||||||
|
id="tags-autocomplete-dropdown">
|
||||||
|
{suggestions.map((item, index) => {
|
||||||
|
const isFirst = index === 0
|
||||||
|
const isLast = index === suggestions.length - 1
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
id={`tag_autocomplete_option_${item.value}`}
|
||||||
|
accessibilityRole="button"
|
||||||
|
key={item.uri}
|
||||||
|
onPress={() => 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,
|
||||||
|
]}>
|
||||||
|
<Text numberOfLines={1}>{item.displayName}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</Pin>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,11 +105,13 @@ import * as Toast from '#/view/com/util/Toast'
|
|||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {atoms as a, native, useTheme} from '#/alf'
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import {FeedsSelector} from '#/components/Composer/FeedsSelector'
|
||||||
import {OutlineTags} from '#/components/Composer/OutlineTags'
|
import {OutlineTags} from '#/components/Composer/OutlineTags'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
||||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
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 {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {Text as NewText} from '#/components/Typography'
|
import {Text as NewText} from '#/components/Typography'
|
||||||
@@ -524,8 +526,13 @@ export const ComposePost = ({
|
|||||||
[dispatch],
|
[dispatch],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onChangeFeeds = useCallback((uris: string[]) => {
|
||||||
|
console.log(uris)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const [showOutlineTags, setShowOutlineTags] = useState(false)
|
const [showOutlineTags, setShowOutlineTags] = useState(false)
|
||||||
const outlineTagsVisible = showOutlineTags || draft.tags.length > 0
|
const outlineTagsVisible = showOutlineTags || draft.tags.length > 0
|
||||||
|
const [showFeeds, setShowFeeds] = useState(false)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
scrollHandler,
|
scrollHandler,
|
||||||
@@ -766,7 +773,7 @@ export const ComposePost = ({
|
|||||||
{outlineTagsVisible && (
|
{outlineTagsVisible && (
|
||||||
<View style={[a.px_lg]}>
|
<View style={[a.px_lg]}>
|
||||||
<Divider />
|
<Divider />
|
||||||
<View style={[a.flex_row, a.gap_xs, a.pt_sm, a.pb_md]}>
|
<View style={[a.flex_row, a.gap_xs, a.py_sm]}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
@@ -782,6 +789,25 @@ export const ComposePost = ({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showFeeds && (
|
||||||
|
<View style={[a.px_lg]}>
|
||||||
|
<Divider />
|
||||||
|
<View style={[a.flex_row, a.gap_xs, a.pt_sm, a.pb_md]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
paddingTop: 1,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<FeedsIcon size="md" />
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<FeedsSelector onChangeFeeds={onChangeFeeds} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
@@ -832,6 +858,20 @@ export const ComposePost = ({
|
|||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!showFeeds && (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Add tags`)}
|
||||||
|
size="small"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
onPress={() => setShowFeeds(true)}>
|
||||||
|
<ButtonIcon icon={FeedsIcon} position="left" />
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Post to feeds</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<View
|
<View
|
||||||
|
|||||||
Reference in New Issue
Block a user