[🏁 #2] Starter pack wizard (#4266)

* minimal landing page

add a minimal gate

remove overlap row

add bg to avatar set

implement basic mock up

add header of mock

add route

* add navigation

* create starter pack landing page

* add state reducer

* make value an array

* implement state w/ steps

* add back the flex

* view header back button

* add button text

* add button text

* add details screen

* add button states

* proper use of button

* oops

* rm extra state

* use keyboard aware scrollview

* rm log

* remove some stuff

* remove avatar from landing

* add list for profiles

* add profile card w/ button

* add feeds

* add processing state

* support editing a pack

* fix undefined error

* add desktop props

* add entire profile to state

* scope the profile list better

* scoping

* add key extractor to feeds

* add fab

* add icon to details

* changes

* add dialog for adding profiles

* remove log

* rm unused iports

* fix nav type
This commit is contained in:
Hailey
2024-06-02 18:14:08 -07:00
committed by GitHub
parent cb581bffd1
commit 30686702a6
18 changed files with 934 additions and 2 deletions
+137
View File
@@ -0,0 +1,137 @@
import React from 'react'
import {AppBskyActorDefs} from '@atproto/api'
const steps = ['Landing', 'Details', 'Profiles', 'Feeds'] as const
type Step = (typeof steps)[number]
type Action =
| {type: 'Next'}
| {type: 'Back'}
| {type: 'SetCanNext'; canNext: boolean}
| {type: 'SetName'; name: string}
| {type: 'SetDescription'; description: string}
| {type: 'AddProfile'; profile: AppBskyActorDefs.ProfileViewBasic}
| {type: 'RemoveProfile'; profileDid: string}
| {type: 'AddFeed'; uri: string}
| {type: 'RemoveFeed'; uri: string}
| {type: 'SetProcessing'; processing: boolean}
interface State {
canNext: boolean
currentStep: Step
name?: string
description?: string
avatar?: string
profiles: AppBskyActorDefs.ProfileViewBasic[]
feedUris: string[]
processing: boolean
}
type TStateContext = [State, (action: Action) => void]
const StateContext = React.createContext<TStateContext>([
{} as State,
(_: Action) => {},
])
export const useWizardState = () => React.useContext(StateContext)
function reducer(state: State, action: Action): State {
let updatedState = state
// -- Navigation
if (action.type === 'Next' && state.currentStep !== 'Feeds') {
const currentIndex = steps.indexOf(state.currentStep)
updatedState = {...state, currentStep: steps[currentIndex + 1]}
} else if (action.type === 'Back' && state.currentStep !== 'Landing') {
const currentIndex = steps.indexOf(state.currentStep)
updatedState = {...state, currentStep: steps[currentIndex - 1]}
}
switch (action.type) {
case 'SetName':
updatedState = {...state, name: action.name}
break
case 'SetDescription':
updatedState = {...state, description: action.description}
break
case 'AddProfile':
updatedState = {...state, profiles: [...state.profiles, action.profile]}
break
case 'RemoveProfile':
updatedState = {
...state,
profiles: state.profiles.filter(
profile => profile.did !== action.profileDid,
),
}
break
case 'AddFeed':
updatedState = {...state, feedUris: [...state.feedUris, action.uri]}
break
case 'RemoveFeed':
updatedState = {
...state,
feedUris: state.feedUris.filter(uri => uri !== action.uri),
}
break
case 'SetProcessing':
updatedState = {...state, processing: action.processing}
break
}
switch (updatedState.currentStep) {
case 'Landing':
updatedState = {
...updatedState,
canNext: true,
}
break
case 'Details':
updatedState = {
...updatedState,
canNext: Boolean(updatedState.description),
}
break
}
return updatedState
}
// TODO supply the initial state to this component
export function Provider({
initialState,
initialStep = 'Landing',
children,
}: {
initialState?: any // TODO update this type
initialStep?: Step
children: React.ReactNode
}) {
const stateAndReducer = React.useReducer(
reducer,
initialState
? {
...initialState,
step: initialStep,
}
: {
canNext: true,
currentStep: initialStep,
profiles: [],
feedUris: [],
processing: false,
},
)
return (
<StateContext.Provider value={stateAndReducer}>
{children}
</StateContext.Provider>
)
}
export {
type Action as WizardAction,
type State as WizardState,
type Step as WizardStep,
}
@@ -0,0 +1,58 @@
import React from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useProfileQuery} from 'state/queries/profile'
import {useSession} from 'state/session'
import {useWizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {StarterPackIcon} from '#/components/icons/StarterPackIcon'
export function StepDetails() {
const {_} = useLingui()
const [state, dispatch] = useWizardState()
const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({
did: currentAccount?.did,
staleTime: 300,
})
return (
<View style={[a.px_xl, a.gap_xl, a.mt_md]}>
<View style={[{height: 65, marginTop: 20, marginBottom: 50}]}>
<StarterPackIcon />
</View>
<View>
<TextField.LabelText>{_(msg`Starter pack name`)}</TextField.LabelText>
<TextField.Input
label={_(
msg`${
currentProfile?.displayName || currentProfile?.handle
}'s starter pack`,
)}
value={state.name}
onChangeText={text => dispatch({type: 'SetName', name: text})}
/>
</View>
<View>
<TextField.LabelText>{_(msg`Description`)}</TextField.LabelText>
<TextField.Root>
<TextField.Input
label={_(
msg`Write a short description of your starter pack. What can new users expect?`,
)}
value={state.description}
onChangeText={text =>
dispatch({type: 'SetDescription', description: text})
}
multiline
style={{minHeight: 150}}
/>
</TextField.Root>
</View>
</View>
)
}
@@ -0,0 +1,88 @@
import React from 'react'
import {ListRenderItemInfo, 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 {useProfileFeedgensQuery} from 'state/queries/profile-feedgens'
import {useSession} from 'state/session'
import {List} from 'view/com/util/List'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {useWizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
function renderItem({item}: ListRenderItemInfo<GeneratorView>) {
return <FeedCard generator={item} />
}
function keyExtractor(item: GeneratorView) {
return item.uri
}
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 includesFeed = state.feedUris.includes(generator.uri)
const onAdd = () => {
if (includesFeed) {
dispatch({type: 'RemoveFeed', uri: generator.uri})
} else {
dispatch({type: 'AddFeed', uri: generator.uri})
}
}
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,33 @@
import React from 'react'
import {View} from 'react-native'
import {Trans} from '@lingui/macro'
import {atoms as a, useTheme} from '#/alf'
import {StarterPackIcon} from '#/components/icons/StarterPackIcon'
import {Text} from '#/components/Typography'
export function StepLanding() {
const t = useTheme()
return (
<View style={[a.flex_1, a.justify_center, {marginTop: 100}]}>
<View style={[{height: 150, marginBottom: 50}]}>
<StarterPackIcon />
</View>
<View style={[a.gap_lg, a.align_center, a.px_md]}>
<Text style={[a.font_bold, a.text_lg, t.atoms.text_contrast_medium]}>
<Trans>Starter packs</Trans>
</Text>
<Text style={[a.font_bold, a.text_4xl]}>
<Trans>Invites, but personal</Trans>
</Text>
<Text style={[a.text_center, a.text_md, a.px_md]}>
<Trans>
Create your own Bluesky starter packs and invite people directly to
your favorite feeds, profiles, and more.
</Trans>
</Text>
</View>
</View>
)
}
@@ -0,0 +1,189 @@
import React, {useLayoutEffect, useRef, useState} from 'react'
import type {ListRenderItemInfo, TextInput as RNTextInput} from 'react-native'
import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isWeb} from '#/platform/detection'
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
import {useProfileFollowsQuery} from 'state/queries/profile-follows'
import {useSession} from 'state/session'
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 * as Dialog from '#/components/Dialog'
import {TextInput} from '#/components/dms/dialogs/TextInput'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Text} from '#/components/Typography'
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) {
return item.did
}
export function WizardAddProfilesDialog({
control,
state,
dispatch,
}: {
control: Dialog.DialogControlProps
state: WizardState
dispatch: (action: WizardAction) => void
}) {
const [searchText, setSearchText] = useState('')
const {currentAccount} = useSession()
const {data: results} = useActorAutocompleteQuery(searchText, true, 12)
const {data: followsPages, fetchNextPage} = useProfileFollowsQuery(
currentAccount?.did,
)
const follows = followsPages?.pages.flatMap(page => page.follows) || []
const listRef = useRef<BottomSheetFlatListMethods>(null)
const inputRef = useRef<RNTextInput>(null)
useLayoutEffect(() => {
if (isWeb) {
setImmediate(() => {
inputRef?.current?.focus()
})
}
}, [])
const renderItem = ({
item,
}: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => {
return (
<WizardProfileCard profile={item} state={state} dispatch={dispatch} />
)
}
return (
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<Dialog.InnerFlatList
ref={listRef}
data={searchText.length > 0 ? results : follows}
renderItem={renderItem}
keyExtractor={keyExtractor}
ListHeaderComponent={
<ListHeader
searchText={searchText}
setSearchText={setSearchText}
inputRef={inputRef}
/>
}
stickyHeaderIndices={[0]}
style={[
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
native({
height: '100%',
paddingHorizontal: 0,
marginTop: 0,
paddingTop: 0,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
}),
]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
keyboardDismissMode="on-drag"
onEndReached={() => fetchNextPage()}
onEndReachedThreshold={2}
removeClippedSubviews={true}
/>
</Dialog.Outer>
)
}
function ListHeader({
searchText,
setSearchText,
inputRef,
}: {
searchText: string
setSearchText: (text: string) => void
inputRef: React.Ref<RNTextInput>
}) {
const t = useTheme()
const {_} = useLingui()
const {
state: hovered,
onIn: onMouseEnter,
onOut: onMouseLeave,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const interacted = hovered || focused
return (
<View
style={[
a.relative,
a.pt_md,
a.pb_xs,
a.px_lg,
a.border_b,
t.atoms.border_contrast_low,
t.atoms.bg,
native([a.pt_lg]),
]}>
<View
style={[
a.relative,
native(a.align_center),
a.justify_center,
{height: 32},
]}>
<Text
style={[
a.z_10,
a.text_lg,
a.font_bold,
a.leading_tight,
t.atoms.text_contrast_high,
]}>
<Trans>Select profiles to add</Trans>
</Text>
</View>
<View style={[native([a.pt_sm]), web([a.pt_xs])]}>
<View
{...web({
onMouseEnter,
onMouseLeave,
})}
style={[a.flex_row, a.align_center, a.gap_sm]}>
<Search
size="md"
fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
/>
<TextInput
// @ts-ignore bottom sheet input types issue — esb
ref={inputRef}
placeholder={_(msg`Search`)}
value={searchText}
onChangeText={setSearchText}
onFocus={onFocus}
onBlur={onBlur}
style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]}
placeholderTextColor={t.palette.contrast_500}
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
returnKeyType="search"
clearButtonMode="while-editing"
maxLength={50}
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
autoFocus
accessibilityLabel={_(msg`Search profiles`)}
accessibilityHint={_(msg`Search profiles`)}
/>
</View>
</View>
</View>
)
}
@@ -0,0 +1,72 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
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 WizardProfileCard({
state,
dispatch,
profile,
}: {
state: WizardState
dispatch: (action: WizardAction) => void
profile: AppBskyActorDefs.ProfileViewBasic
}) {
const {_} = useLingui()
const t = useTheme()
const includesProfile = state.profiles.some(p => p.did === profile.did)
const onPressAddRemove = () => {
if (!profile?.did) return
if (!includesProfile) {
dispatch({type: 'AddProfile', profile})
} else {
dispatch({type: 'RemoveProfile', profileDid: profile.did})
}
}
return (
<View
style={[
a.flex_row,
a.align_center,
a.px_md,
a.py_sm,
a.gap_md,
a.border_b,
t.atoms.border_contrast_low,
]}>
<UserAvatar size={45} avatar={profile?.avatar} />
<View style={[a.flex_1]}>
<Text style={[a.flex_1, a.font_bold, a.text_md]} numberOfLines={1}>
{profile?.displayName || profile?.handle}
</Text>
<Text
style={[a.flex_1, t.atoms.text_contrast_medium]}
numberOfLines={1}>
@{profile?.handle}
</Text>
</View>
<Button
label={includesProfile ? _(msg`Remove`) : _(msg`Add`)}
variant="solid"
color={includesProfile ? 'secondary' : 'primary'}
size="small"
style={{paddingVertical: 6}}
onPress={onPressAddRemove}>
<ButtonText>
{includesProfile ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
</ButtonText>
</Button>
</View>
)
}
@@ -0,0 +1,52 @@
import React from 'react'
import {ListRenderItemInfo, View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {List} from 'view/com/util/List'
import {useWizardState} from '#/screens/StarterPack/Wizard/State'
import {WizardProfileCard} from '#/screens/StarterPack/Wizard/StepProfiles/WizardProfileCard'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) {
return item.did
}
export function StepProfiles() {
const [state, dispatch] = useWizardState()
const renderItem = ({
item,
}: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => {
return (
<WizardProfileCard profile={item} state={state} dispatch={dispatch} />
)
}
return (
<>
<View style={[a.flex_1]}>
{state.profiles.length > 0 ? (
<List
data={state.profiles}
renderItem={renderItem}
keyExtractor={keyExtractor}
/>
) : (
<ListEmpty />
)}
</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>
)
}
+228
View File
@@ -0,0 +1,228 @@
import React from 'react'
import {Keyboard, View} from 'react-native'
import {
KeyboardAwareScrollView,
KeyboardStickyView,
} from 'react-native-keyboard-controller'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types'
import {useProfileQuery} from 'state/queries/profile'
import {useSession} from 'state/session'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {CenteredView} from 'view/com/util/Views'
import {useWizardState, WizardStep} from '#/screens/StarterPack/Wizard/State'
import {StepDetails} from '#/screens/StarterPack/Wizard/StepDetails'
import {StepFeeds} from '#/screens/StarterPack/Wizard/StepFeeds'
import {StepLanding} from '#/screens/StarterPack/Wizard/StepLanding'
import {StepProfiles} from '#/screens/StarterPack/Wizard/StepProfiles'
import {WizardAddProfilesDialog} from '#/screens/StarterPack/Wizard/StepProfiles/WizardAddProfilesDialog'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import {Provider} from './State'
export function Wizard({
route,
}: NativeStackScreenProps<CommonNavigatorParams, 'StarterPackWizard'>) {
const params = route.params
const {mode, initialStep, id} = params ?? {}
// TODO load query here
const starterPack = {}
// TODO use this to wait for loading the starterpack for editing
if (mode === 'Edit' && false) {
// Await here
return
}
return (
<WizardReady
mode={mode}
id={id}
initialStep={initialStep}
starterPack={starterPack}
/>
)
}
function WizardReady({
mode,
initialStep,
starterPack,
}: {
mode: 'Create' | 'Edit'
id?: string
initialStep?: 'Details' | 'Profiles' | 'Feeds'
starterPack?: any
}) {
return (
<Provider
initialState={mode === 'Edit' ? starterPack : undefined}
initialStep={initialStep}>
<WizardInner />
</Provider>
)
}
function WizardInner() {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const t = useTheme()
const bottomOffset = useBottomBarOffset()
const [state, dispatch] = useWizardState()
const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({
did: currentAccount?.did,
staleTime: 0,
})
const bottomBarOffset = useBottomBarOffset()
const addProfilesControl = useDialogControl()
React.useEffect(() => {
navigation.setOptions({
gestureEnabled: false,
})
}, [navigation])
const wizardUiStrings: Record<WizardStep, {header: string; button: string}> =
{
Landing: {
header: _(msg`Create a starter pack`),
button: _(msg`Create`),
},
Details: {
header: _(msg`Details`),
button: _(msg`Add profiles`),
},
Profiles: {
header: _(msg`Add profiles`),
button: _(msg`Add feeds`),
},
Feeds: {
header: _(msg`Add feeds`),
button: _(msg`Finish`),
},
}
const uiStrings = wizardUiStrings[state.currentStep]
const onNext = () => {
if (state.currentStep === 'Details' && !state.name) {
dispatch({
type: 'SetName',
name: _(
msg`${currentProfile?.displayName || currentProfile?.handle}'s`,
),
})
} else if (state.currentStep === 'Feeds') {
dispatch({type: 'SetProcessing', processing: true})
return
}
const keyboardVisible = Keyboard.isVisible()
Keyboard.dismiss()
setTimeout(
() => {
dispatch({type: 'Next'})
},
keyboardVisible ? 16 : 0,
)
}
return (
<CenteredView
style={[a.flex_1, {marginBottom: bottomOffset + 20}]}
sideBorders>
<ViewHeader
title={uiStrings.header}
onBackPress={
state.currentStep !== 'Landing'
? () => dispatch({type: 'Back'})
: undefined
}
showBorder={true}
showOnDesktop={true}
renderButton={
state.currentStep === 'Profiles'
? () => (
<Button
label={_(msg`Cancel`)}
variant="solid"
color="primary"
size="xsmall"
onPress={addProfilesControl.open}
style={{marginLeft: -15}}>
<ButtonText>
<Trans>Add</Trans>
</ButtonText>
</Button>
)
: undefined
}
/>
<Container>
<StepView />
</Container>
<KeyboardStickyView offset={{opened: bottomBarOffset}}>
<View style={a.px_md}>
<Button
label={uiStrings.button}
variant="solid"
color="primary"
size="large"
onPress={onNext}
disabled={!state.canNext || state.processing}>
<ButtonText>{uiStrings.button}</ButtonText>
{state.processing && (
<Loader size="md" style={t.atoms.text_contrast_low} />
)}
</Button>
</View>
</KeyboardStickyView>
<WizardAddProfilesDialog
control={addProfilesControl}
state={state}
dispatch={dispatch}
/>
</CenteredView>
)
}
function Container({children}: {children: React.ReactNode}) {
const [state] = useWizardState()
if (state.currentStep === 'Profiles' || state.currentStep === 'Feeds') {
return <View style={[a.flex_1]}>{children}</View>
}
return (
<KeyboardAwareScrollView style={[a.flex_1]}>
{children}
</KeyboardAwareScrollView>
)
}
function StepView() {
const [state] = useWizardState()
if (state.currentStep === 'Landing') {
return <StepLanding />
}
if (state.currentStep === 'Details') {
return <StepDetails />
}
if (state.currentStep === 'Profiles') {
return <StepProfiles />
}
if (state.currentStep === 'Feeds') {
return <StepFeeds />
}
}