[🏁 #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
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 153 133"><path fill="url(#a)" fill-rule="evenodd" d="m60.196 105.445-18.1 4.85c-11.73 3.143-23.788-3.819-26.931-15.55L1.19 42.597c-3.143-11.731 3.819-23.79 15.55-26.932L68.889 1.69C80.62-1.452 92.68 5.51 95.821 17.241l4.667 17.416a49.7 49.7 0 0 1 3.522-.125c27.053 0 48.984 21.931 48.984 48.984S131.063 132.5 104.01 132.5c-19.17 0-35.769-11.012-43.814-27.055ZM19.457 25.804 71.606 11.83c6.131-1.643 12.434 1.996 14.076 8.127l4.44 16.571c-20.289 5.987-35.096 24.758-35.096 46.988 0 4.157.517 8.193 1.492 12.047l-17.138 4.593c-6.131 1.642-12.434-1.996-14.077-8.128L11.33 39.88c-1.643-6.131 1.996-12.434 8.127-14.077Zm83.812 19.232c.246-.005.493-.007.741-.007 21.256 0 38.487 17.231 38.487 38.487s-17.231 38.488-38.487 38.488c-14.29 0-26.76-7.788-33.4-19.35l23.635-6.333c11.731-3.143 18.693-15.2 15.55-26.932l-6.526-24.353Zm-10.428 1.638 6.815 25.432c1.642 6.131-1.996 12.434-8.128 14.076l-24.867 6.664a38.57 38.57 0 0 1-1.139-9.33c0-17.372 11.51-32.056 27.32-36.842Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="76.715" x2="76.715" y1=".937" y2="132.5" gradientUnits="userSpaceOnUse"><stop stop-color="#0A7AFF"/><stop offset="1" stop-color="#59B9FF"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+6
View File
@@ -44,6 +44,7 @@ import HashtagScreen from '#/screens/Hashtag'
import {ModerationScreen} from '#/screens/Moderation'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {Landing} from '#/screens/StarterPack/Landing'
import {Wizard} from '#/screens/StarterPack/Wizard'
import {init as initAnalytics} from './lib/analytics/analytics'
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
@@ -312,6 +313,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => Landing}
options={{title: title(msg`Join Bluesky Today!`)}}
/>
<Stack.Screen
name="StarterPackWizard"
getComponent={() => Wizard}
options={{title: title(msg`Create a starter pack`), requireAuth: true}}
/>
</>
)
}
+2
View File
@@ -140,6 +140,7 @@ export function createInput(Component: typeof TextInput) {
onChangeText,
isInvalid,
inputRef,
style,
...rest
}: InputProps) {
const t = useTheme()
@@ -199,6 +200,7 @@ export function createInput(Component: typeof TextInput) {
android({
paddingBottom: 16,
}),
style,
]}
/>
+26
View File
@@ -0,0 +1,26 @@
import * as React from 'react'
import Svg, {Defs, LinearGradient, Path, Stop} from 'react-native-svg'
export function StarterPackIcon({...props}: React.ComponentProps<typeof Svg>) {
return (
<Svg fill="none" viewBox="0 0 153 133" {...props}>
<Path
fill="url(#a)"
fillRule="evenodd"
d="m60.196 105.445-18.1 4.85c-11.73 3.143-23.788-3.819-26.931-15.55L1.19 42.597c-3.143-11.731 3.819-23.79 15.55-26.932L68.889 1.69C80.62-1.452 92.68 5.51 95.821 17.241l4.667 17.416a49.7 49.7 0 0 1 3.522-.125c27.053 0 48.984 21.931 48.984 48.984S131.063 132.5 104.01 132.5c-19.17 0-35.769-11.012-43.814-27.055ZM19.457 25.804 71.606 11.83c6.131-1.643 12.434 1.996 14.076 8.127l4.44 16.571c-20.289 5.987-35.096 24.758-35.096 46.988 0 4.157.517 8.193 1.492 12.047l-17.138 4.593c-6.131 1.642-12.434-1.996-14.077-8.128L11.33 39.88c-1.643-6.131 1.996-12.434 8.127-14.077Zm83.812 19.232c.246-.005.493-.007.741-.007 21.256 0 38.487 17.231 38.487 38.487s-17.231 38.488-38.487 38.488c-14.29 0-26.76-7.788-33.4-19.35l23.635-6.333c11.731-3.143 18.693-15.2 15.55-26.932l-6.526-24.353Zm-10.428 1.638 6.815 25.432c1.642 6.131-1.996 12.434-8.128 14.076l-24.867 6.664a38.57 38.57 0 0 1-1.139-9.33c0-17.372 11.51-32.056 27.32-36.842Z"
clipRule="evenodd"
/>
<Defs>
<LinearGradient
id="a"
x1={76.715}
x2={76.715}
y1={0.937}
y2={132.5}
gradientUnits="userSpaceOnUse">
<Stop stopColor="#0A7AFF" />
<Stop offset={1} stopColor="#59B9FF" />
</LinearGradient>
</Defs>
</Svg>
)
}
+11
View File
@@ -0,0 +1,11 @@
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {clamp} from 'lib/numbers'
import {isWeb} from 'platform/detection'
export function useBottomBarOffset() {
const {isTabletOrDesktop} = useWebMediaQueries()
const {bottom: bottomInset} = useSafeAreaInsets()
return isWeb && isTabletOrDesktop ? 0 : clamp(60 + bottomInset, 60, 75)
}
+10
View File
@@ -41,6 +41,11 @@ export type CommonNavigatorParams = {
MessagesConversation: {conversation: string; embed?: string}
MessagesSettings: undefined
StarterPackLanding: {id: string}
StarterPackWizard: {
mode: 'Create' | 'Edit'
id?: string
initialStep?: 'Details' | 'Profiles' | 'Feeds'
}
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {
@@ -99,6 +104,11 @@ export type AllNavigatorParams = CommonNavigatorParams & {
MessagesTab: undefined
Messages: {animation?: 'push' | 'pop'}
StarterPackLanding: {id: string}
StarterPackWizard: {
mode: 'Create' | 'Edit'
id?: string
initialStep?: 'Details' | 'Profiles' | 'Feeds'
}
}
// NOTE
+1
View File
@@ -41,4 +41,5 @@ export const router = new Router({
MessagesSettings: '/messages/settings',
MessagesConversation: '/messages/:conversation',
StarterPackLanding: '/start/:id',
StarterPackWizard: '/starter-pack/create',
})
+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 />
}
}
+8 -2
View File
@@ -28,6 +28,7 @@ export function ViewHeader({
showOnDesktop,
showBorder,
renderButton,
onBackPress: onPressBackOverride,
}: {
title: string
subtitle?: string
@@ -37,6 +38,7 @@ export function ViewHeader({
showOnDesktop?: boolean
showBorder?: boolean
renderButton?: () => JSX.Element
onBackPress?: () => void
}) {
const pal = usePalette('default')
const {_} = useLingui()
@@ -48,11 +50,15 @@ export function ViewHeader({
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
if (typeof onPressBackOverride === 'function') {
onPressBackOverride()
} else {
navigation.goBack()
}
} else {
navigation.navigate('Home')
}
}, [navigation])
}, [navigation, onPressBackOverride])
const onPressMenu = React.useCallback(() => {
track('ViewHeader:MenuButtonClicked')
+12
View File
@@ -876,6 +876,18 @@ export function SettingsScreen({}: Props) {
<Trans>Delete chat declaration record</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[pal.view, styles.linkCardNoIcon]}
onPress={() =>
navigation.navigate('StarterPackWizard', {mode: 'Create'})
}
accessibilityRole="button"
accessibilityLabel={_(msg`Navigate to Starter Pack Wizard`)}
accessibilityHint={_(msg`Navigates to Starter Pack Wizard`)}>
<Text type="lg" style={pal.text}>
<Trans>Navigate to Starter Pack Wizard</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[pal.view, styles.linkCardNoIcon]}
onPress={onPressResetOnboarding}