* 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:
@@ -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 |
@@ -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}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -41,4 +41,5 @@ export const router = new Router({
|
||||
MessagesSettings: '/messages/settings',
|
||||
MessagesConversation: '/messages/:conversation',
|
||||
StarterPackLanding: '/start/:id',
|
||||
StarterPackWizard: '/starter-pack/create',
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user