[🏁 #10] Searching for feeds (#4368)

* refactor name

* refactor to support feeds search

* more refactor

* cleanup

* allow searching for feeds

* better empty states

* move some stuff around

* one more move

* rename buttons

* another move

* clarify variable names
This commit is contained in:
Hailey
2024-06-04 17:12:41 -07:00
committed by GitHub
parent 6cc82a41fc
commit 1c28bf47f2
8 changed files with 271 additions and 131 deletions
@@ -2,45 +2,131 @@ import React, {useLayoutEffect, useRef, useState} from 'react'
import type {ListRenderItemInfo, TextInput as RNTextInput} from 'react-native' import type {ListRenderItemInfo, TextInput as RNTextInput} from 'react-native'
import {View} from 'react-native' import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import debounce from 'lodash.debounce'
import {isWeb} from '#/platform/detection' import {isWeb} from 'platform/detection'
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete' import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
import {
useGetPopularFeedsQuery,
useSearchPopularFeedsMutation,
} from 'state/queries/feed'
import {useProfileFollowsQuery} from 'state/queries/profile-follows' import {useProfileFollowsQuery} from 'state/queries/profile-follows'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State' import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
import {WizardProfileCard} from '#/screens/StarterPack/Wizard/StepProfiles/WizardProfileCard'
import {atoms as a, native, useTheme, web} from '#/alf' import {atoms as a, native, useTheme, web} from '#/alf'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {TextInput} from '#/components/dms/dialogs/TextInput' import {TextInput} from '#/components/dms/dialogs/TextInput'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {WizardFeedCard} from '#/components/StarterPack/Wizard/WizardFeedCard'
import {WizardProfileCard} from '#/components/StarterPack/Wizard/WizardProfileCard'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) { interface Props {
return item.did
}
export function WizardAddProfilesDialog({
control,
state,
dispatch,
}: {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
type: 'profiles' | 'feeds'
state: WizardState state: WizardState
dispatch: (action: WizardAction) => void dispatch: (action: WizardAction) => void
}) { }
function keyExtractor(
item: AppBskyActorDefs.ProfileViewBasic | GeneratorView,
index: number,
) {
return `${item.did}-${index}`
}
export function WizardAddDialog(props: Props) {
if (props.type === 'profiles') {
return <AddProfiles {...props} />
}
return <AddFeeds {...props} />
}
function AddProfiles(props: Props) {
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {data: results} = useActorAutocompleteQuery(searchText, true, 12)
const {data: followsPages, fetchNextPage} = useProfileFollowsQuery( const {data: followsPages, fetchNextPage} = useProfileFollowsQuery(
currentAccount?.did, currentAccount?.did,
) )
const follows = followsPages?.pages.flatMap(page => page.follows) || [] const follows = followsPages?.pages.flatMap(page => page.follows) || []
const {data: searchedProfiles} = useActorAutocompleteQuery(
searchText,
true,
12,
)
return (
<AddDialog
{...props}
data={searchText ? searchedProfiles : follows}
onEndReached={searchText ? undefined : () => fetchNextPage()}
searchText={searchText}
setSearchText={setSearchText}
/>
)
}
function AddFeeds(props: Props) {
const [searchText, setSearchText] = useState('')
const {data: popularFeedsPages, fetchNextPage} = useGetPopularFeedsQuery()
const popularFeeds =
popularFeedsPages?.pages.flatMap(page => page.feeds) || []
const {
data: searchedFeeds,
mutate: search,
reset: resetSearch,
} = useSearchPopularFeedsMutation()
const debouncedSearch = React.useMemo(
() => debounce(q => search(q), 500), // debounce for 500ms
[search],
)
const onChangeText = (text: string) => {
setSearchText(text)
if (text.length > 1) {
debouncedSearch(text)
} else {
resetSearch()
}
}
return (
<AddDialog
{...props}
data={searchText ? searchedFeeds : popularFeeds}
onEndReached={() => fetchNextPage()}
searchText={searchText}
setSearchText={onChangeText}
/>
)
}
function AddDialog({
type,
control,
state,
dispatch,
data,
onEndReached,
searchText,
setSearchText,
}: Props & {
data?: AppBskyActorDefs.ProfileViewBasic[] | GeneratorView[]
onEndReached?: () => void
searchText: string
setSearchText: (text: string) => void
}) {
const listRef = useRef<BottomSheetFlatListMethods>(null) const listRef = useRef<BottomSheetFlatListMethods>(null)
const inputRef = useRef<RNTextInput>(null) const inputRef = useRef<RNTextInput>(null)
@@ -52,13 +138,12 @@ export function WizardAddProfilesDialog({
} }
}, []) }, [])
const renderItem = ({ const renderItem = ({item}: ListRenderItemInfo<any>) =>
item, type === 'profiles' ? (
}: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => {
return (
<WizardProfileCard profile={item} state={state} dispatch={dispatch} /> <WizardProfileCard profile={item} state={state} dispatch={dispatch} />
) : (
<WizardFeedCard generator={item} state={state} dispatch={dispatch} />
) )
}
return ( return (
<Dialog.Outer <Dialog.Outer
@@ -67,7 +152,7 @@ export function WizardAddProfilesDialog({
nativeOptions={{sheet: {snapPoints: ['100%']}}}> nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<Dialog.InnerFlatList <Dialog.InnerFlatList
ref={listRef} ref={listRef}
data={searchText.length > 0 ? results : follows} data={data}
renderItem={renderItem} renderItem={renderItem}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
ListHeaderComponent={ ListHeaderComponent={
@@ -75,6 +160,7 @@ export function WizardAddProfilesDialog({
searchText={searchText} searchText={searchText}
setSearchText={setSearchText} setSearchText={setSearchText}
inputRef={inputRef} inputRef={inputRef}
type={type}
/> />
} }
stickyHeaderIndices={[0]} stickyHeaderIndices={[0]}
@@ -91,7 +177,7 @@ export function WizardAddProfilesDialog({
]} ]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
onEndReached={() => fetchNextPage()} onEndReached={onEndReached}
onEndReachedThreshold={2} onEndReachedThreshold={2}
removeClippedSubviews={true} removeClippedSubviews={true}
/> />
@@ -100,10 +186,12 @@ export function WizardAddProfilesDialog({
} }
function ListHeader({ function ListHeader({
type,
searchText, searchText,
setSearchText, setSearchText,
inputRef, inputRef,
}: { }: {
type: 'profiles' | 'feeds'
searchText: string searchText: string
setSearchText: (text: string) => void setSearchText: (text: string) => void
inputRef: React.Ref<RNTextInput> inputRef: React.Ref<RNTextInput>
@@ -145,7 +233,11 @@ function ListHeader({
a.leading_tight, a.leading_tight,
t.atoms.text_contrast_high, t.atoms.text_contrast_high,
]}> ]}>
<Trans>Select profiles to add</Trans> {type === 'profiles' ? (
<Trans>Select profiles to add</Trans>
) : (
<Trans>Select feeds to add</Trans>
)}
</Text> </Text>
</View> </View>
@@ -0,0 +1,69 @@
import React from 'react'
import {View} from 'react-native'
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
export function WizardFeedCard({
generator,
state,
dispatch,
}: {
generator: GeneratorView
state: WizardState
dispatch: (action: WizardAction) => void
}) {
const {_} = useLingui()
const t = useTheme()
const includesFeed = state.feeds.some(f => f.uri === generator.uri)
const onAdd = () => {
if (includesFeed) {
dispatch({type: 'RemoveFeed', feedUri: generator.uri})
} else {
dispatch({type: 'AddFeed', feed: generator})
}
}
return (
<View
style={[
a.flex_row,
a.align_center,
a.px_md,
a.py_sm,
a.border_b,
a.gap_md,
t.atoms.border_contrast_low,
]}>
<UserAvatar type="algo" size={45} avatar={generator.avatar} />
<View style={[a.flex_1]}>
<Text style={[a.flex_1, a.font_bold, a.text_md]} numberOfLines={1}>
{generator.displayName}
</Text>
<Text
style={[a.flex_1, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{_(msg`Feed by @${generator.creator.handle}`)}
</Text>
</View>
<Button
label={includesFeed ? _(msg`Remove`) : _(msg`Add`)}
variant="solid"
color={includesFeed ? 'secondary' : 'primary'}
size="small"
style={{paddingVertical: 6}}
onPress={onAdd}>
<ButtonText>
{includesFeed ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
</ButtonText>
</Button>
</View>
)
}
@@ -0,0 +1,44 @@
import React from 'react'
import {View} from 'react-native'
import {Trans} from '@lingui/macro'
import {atoms as a, useTheme} from '#/alf'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {UserCircle_Stroke2_Corner0_Rounded as UserCircle} from '#/components/icons/UserCircle'
import {Text} from '#/components/Typography'
export function WizardListEmpty({type}: {type: 'profiles' | 'feeds'}) {
const t = useTheme()
return (
<View
style={[a.flex_1, a.px_md, a.align_center, a.gap_md, {marginTop: 80}]}>
{type === 'profiles' ? (
<UserCircle
width={100}
height={100}
style={t.atoms.text_contrast_medium}
/>
) : (
<Hashtag
width={100}
height={100}
style={t.atoms.text_contrast_medium}
/>
)}
<Text
style={[
a.font_bold,
a.text_xl,
a.text_center,
t.atoms.text_contrast_medium,
]}>
{type === 'profiles' ? (
<Trans>Recommend people to follow!</Trans>
) : (
<Trans>Add some cool feeds!</Trans>
)}
</Text>
</View>
)
}
+7 -6
View File
@@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import {AppBskyActorDefs} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
const steps = ['Landing', 'Details', 'Profiles', 'Feeds'] as const const steps = ['Landing', 'Details', 'Profiles', 'Feeds'] as const
type Step = (typeof steps)[number] type Step = (typeof steps)[number]
@@ -12,8 +13,8 @@ type Action =
| {type: 'SetDescription'; description: string} | {type: 'SetDescription'; description: string}
| {type: 'AddProfile'; profile: AppBskyActorDefs.ProfileViewBasic} | {type: 'AddProfile'; profile: AppBskyActorDefs.ProfileViewBasic}
| {type: 'RemoveProfile'; profileDid: string} | {type: 'RemoveProfile'; profileDid: string}
| {type: 'AddFeed'; uri: string} | {type: 'AddFeed'; feed: GeneratorView}
| {type: 'RemoveFeed'; uri: string} | {type: 'RemoveFeed'; feedUri: string}
| {type: 'SetProcessing'; processing: boolean} | {type: 'SetProcessing'; processing: boolean}
interface State { interface State {
@@ -23,7 +24,7 @@ interface State {
description?: string description?: string
avatar?: string avatar?: string
profiles: AppBskyActorDefs.ProfileViewBasic[] profiles: AppBskyActorDefs.ProfileViewBasic[]
feedUris: string[] feeds: GeneratorView[]
processing: boolean processing: boolean
} }
@@ -66,12 +67,12 @@ function reducer(state: State, action: Action): State {
} }
break break
case 'AddFeed': case 'AddFeed':
updatedState = {...state, feedUris: [...state.feedUris, action.uri]} updatedState = {...state, feeds: [...state.feeds, action.feed]}
break break
case 'RemoveFeed': case 'RemoveFeed':
updatedState = { updatedState = {
...state, ...state,
feedUris: state.feedUris.filter(uri => uri !== action.uri), feeds: state.feeds.filter(f => f.uri !== action.feedUri),
} }
break break
case 'SetProcessing': case 'SetProcessing':
@@ -118,7 +119,7 @@ export function Provider({
canNext: true, canNext: true,
currentStep: initialStep, currentStep: initialStep,
profiles: [], profiles: [],
feedUris: [], feeds: [],
processing: false, processing: false,
}, },
) )
+15 -70
View File
@@ -1,88 +1,33 @@
import React from 'react' import React from 'react'
import {ListRenderItemInfo, View} from 'react-native' import {ListRenderItemInfo} from 'react-native'
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useProfileFeedgensQuery} from 'state/queries/profile-feedgens'
import {useSession} from 'state/session'
import {List} from 'view/com/util/List' import {List} from 'view/com/util/List'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {useWizardState} from '#/screens/StarterPack/Wizard/State' import {useWizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, useTheme} from '#/alf' import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {WizardFeedCard} from '#/components/StarterPack/Wizard/WizardFeedCard'
import {Text} from '#/components/Typography' import {WizardListEmpty} from '#/components/StarterPack/Wizard/WizardListEmpty'
function renderItem({item}: ListRenderItemInfo<GeneratorView>) {
return <FeedCard generator={item} />
}
function keyExtractor(item: GeneratorView) { function keyExtractor(item: GeneratorView) {
return item.uri return item.uri
} }
export function StepFeeds() { export function StepFeeds() {
const {currentAccount} = useSession()
const {data} = useProfileFeedgensQuery(currentAccount!.did)
const feeds = data?.pages.flatMap(page => page.feeds) || []
return (
<List
data={feeds}
renderItem={renderItem}
keyExtractor={keyExtractor}
style={[a.flex_1]}
/>
)
}
function FeedCard({generator}: {generator: GeneratorView}) {
const {_} = useLingui()
const t = useTheme()
const [state, dispatch] = useWizardState() const [state, dispatch] = useWizardState()
const includesFeed = state.feedUris.includes(generator.uri) console.log(state)
const onAdd = () => {
if (includesFeed) { const renderItem = ({item}: ListRenderItemInfo<GeneratorView>) => {
dispatch({type: 'RemoveFeed', uri: generator.uri}) return <WizardFeedCard generator={item} state={state} dispatch={dispatch} />
} else {
dispatch({type: 'AddFeed', uri: generator.uri})
}
} }
return ( return (
<View <List
style={[ data={state.feeds}
a.flex_row, renderItem={renderItem}
a.align_center, keyExtractor={keyExtractor}
a.px_md, style={[a.flex_1]}
a.py_sm, ListEmptyComponent={<WizardListEmpty type="feeds" />}
a.border_b, />
a.gap_md,
t.atoms.border_contrast_low,
]}>
<UserAvatar type="algo" size={45} avatar={generator.avatar} />
<View style={[a.flex_1]}>
<Text style={[a.flex_1, a.font_bold, a.text_md]} numberOfLines={1}>
{generator.displayName}
</Text>
<Text
style={[a.flex_1, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{_(msg`Feed by @${generator.creator.handle}`)}
</Text>
</View>
<Button
label={includesFeed ? _(msg`Remove`) : _(msg`Add`)}
variant="solid"
color={includesFeed ? 'secondary' : 'primary'}
size="small"
style={{paddingVertical: 6}}
onPress={onAdd}>
<ButtonText>
{includesFeed ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
</ButtonText>
</Button>
</View>
) )
} }
@@ -1,13 +1,12 @@
import React from 'react' import React from 'react'
import {ListRenderItemInfo, View} from 'react-native' import {ListRenderItemInfo, View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {List} from 'view/com/util/List' import {List} from 'view/com/util/List'
import {useWizardState} from '#/screens/StarterPack/Wizard/State' import {useWizardState} from '#/screens/StarterPack/Wizard/State'
import {WizardProfileCard} from '#/screens/StarterPack/Wizard/StepProfiles/WizardProfileCard'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography' import {WizardListEmpty} from '#/components/StarterPack/Wizard/WizardListEmpty'
import {WizardProfileCard} from '#/components/StarterPack/Wizard/WizardProfileCard'
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) { function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) {
return item.did return item.did
@@ -27,26 +26,13 @@ export function StepProfiles() {
return ( return (
<> <>
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
{state.profiles.length > 0 ? ( <List
<List data={state.profiles}
data={state.profiles} renderItem={renderItem}
renderItem={renderItem} keyExtractor={keyExtractor}
keyExtractor={keyExtractor} ListEmptyComponent={<WizardListEmpty type="profiles" />}
/> />
) : (
<ListEmpty />
)}
</View> </View>
</> </>
) )
} }
function ListEmpty() {
return (
<View style={[a.flex_1, a.px_md, a.align_center]}>
<Text style={[a.font_bold, a.text_xl, a.text_center, {marginTop: 100}]}>
<Trans>Add the people you recommend to your starter pack!</Trans>
</Text>
</View>
)
}
+15 -12
View File
@@ -20,11 +20,11 @@ import {StepDetails} from '#/screens/StarterPack/Wizard/StepDetails'
import {StepFeeds} from '#/screens/StarterPack/Wizard/StepFeeds' import {StepFeeds} from '#/screens/StarterPack/Wizard/StepFeeds'
import {StepLanding} from '#/screens/StarterPack/Wizard/StepLanding' import {StepLanding} from '#/screens/StarterPack/Wizard/StepLanding'
import {StepProfiles} from '#/screens/StarterPack/Wizard/StepProfiles' import {StepProfiles} from '#/screens/StarterPack/Wizard/StepProfiles'
import {WizardAddProfilesDialog} from '#/screens/StarterPack/Wizard/StepProfiles/WizardAddProfilesDialog'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {WizardAddDialog} from '#/components/StarterPack/Wizard/WizardAddDialog'
import {Provider} from './State' import {Provider} from './State'
export function Wizard({ export function Wizard({
@@ -83,7 +83,7 @@ function WizardInner() {
staleTime: 0, staleTime: 0,
}) })
const bottomBarOffset = useBottomBarOffset() const bottomBarOffset = useBottomBarOffset()
const addProfilesControl = useDialogControl() const searchDialogControl = useDialogControl()
React.useEffect(() => { React.useEffect(() => {
navigation.setOptions({ navigation.setOptions({
@@ -95,15 +95,15 @@ function WizardInner() {
{ {
Landing: { Landing: {
header: _(msg`Create a starter pack`), header: _(msg`Create a starter pack`),
button: _(msg`Create`), button: _(msg`Get started`),
}, },
Details: { Details: {
header: _(msg`Details`), header: _(msg`Details`),
button: _(msg`Add profiles`), button: _(msg`Continue`),
}, },
Profiles: { Profiles: {
header: _(msg`Add profiles`), header: _(msg`Add profiles`),
button: _(msg`Add feeds`), button: _(msg`Continue`),
}, },
Feeds: { Feeds: {
header: _(msg`Add feeds`), header: _(msg`Add feeds`),
@@ -150,14 +150,14 @@ function WizardInner() {
showBorder={true} showBorder={true}
showOnDesktop={true} showOnDesktop={true}
renderButton={ renderButton={
state.currentStep === 'Profiles' state.currentStep === 'Profiles' || state.currentStep === 'Feeds'
? () => ( ? () => (
<Button <Button
label={_(msg`Cancel`)} label={_(msg`Cancel`)}
variant="solid" variant="solid"
color="primary" color="primary"
size="xsmall" size="xsmall"
onPress={addProfilesControl.open} onPress={searchDialogControl.open}
style={{marginLeft: -15}}> style={{marginLeft: -15}}>
<ButtonText> <ButtonText>
<Trans>Add</Trans> <Trans>Add</Trans>
@@ -187,11 +187,14 @@ function WizardInner() {
</View> </View>
</KeyboardStickyView> </KeyboardStickyView>
<WizardAddProfilesDialog {(state.currentStep === 'Profiles' || state.currentStep === 'Feeds') && (
control={addProfilesControl} <WizardAddDialog
state={state} control={searchDialogControl}
dispatch={dispatch} state={state}
/> dispatch={dispatch}
type={state.currentStep === 'Profiles' ? 'profiles' : 'feeds'}
/>
)}
</CenteredView> </CenteredView>
) )
} }