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 && (
+
+ )}