Files
bsky-social-app/src/screens/StarterPack/Wizard/index.tsx
T
Samuel Newman d6af1c5d36 point the remaining view moderation call sites at the sdk
The rest of the consumer surface - components, screens and the legacy `view`
tree - takes its moderation types from `@bsky.app/sdk/moderation` and its
`moderate*` calls from the `lib/moderation/subjects` seam.

`DebugMod` constructs a `ModerationOpts` by hand for its scenario matrix, so
its literal `userDid` is branded at the construction site.
2026-08-13 22:13:47 +03:00

637 lines
19 KiB
TypeScript

import {useEffect} from 'react'
import {Keyboard, View} from 'react-native'
import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Image} from 'expo-image'
import {
type AppBskyActorDefs,
type AppBskyFeedDefs,
type AppBskyGraphDefs,
AtUri,
} from '@atproto/api'
import {type ModerationOpts} from '@bsky.app/sdk/moderation'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {STARTER_PACK_MAX_SIZE} from '#/lib/constants'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {
type CommonNavigatorParams,
type NavigationProp,
} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {enforceLen} from '#/lib/strings/helpers'
import {
getStarterPackOgCard,
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAllListMembersQuery} from '#/state/queries/list-members'
import {useProfileQuery} from '#/state/queries/profile'
import {
useCreateStarterPackMutation,
useEditStarterPackMutation,
useStarterPackQuery,
} from '#/state/queries/starter-packs'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {
useWizardState,
type WizardStep,
} from '#/screens/StarterPack/Wizard/State'
import {StepDetails} from '#/screens/StarterPack/Wizard/StepDetails'
import {StepFeeds} from '#/screens/StarterPack/Wizard/StepFeeds'
import {StepProfiles} from '#/screens/StarterPack/Wizard/StepProfiles'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import * as Layout from '#/components/Layout'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import {WizardEditListDialog} from '#/components/StarterPack/Wizard/WizardEditListDialog'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import type * as bsky from '#/types/bsky'
import {Provider} from './State'
export function Wizard({
route,
}: NativeStackScreenProps<
CommonNavigatorParams,
'StarterPackEdit' | 'StarterPackWizard'
>) {
const params = route.params ?? {}
const rkey = 'rkey' in params ? params.rkey : undefined
const fromDialog = 'fromDialog' in params ? params.fromDialog : false
const targetDid = 'targetDid' in params ? params.targetDid : undefined
const onSuccess = 'onSuccess' in params ? params.onSuccess : undefined
const {currentAccount} = useSession()
const moderationOpts = useModerationOpts()
const {_} = useLingui()
// Use targetDid if provided (from dialog), otherwise use current account
const profileDid = targetDid || currentAccount!.did
const {
data: starterPack,
isLoading: isLoadingStarterPack,
isError: isErrorStarterPack,
} = useStarterPackQuery({did: currentAccount!.did, rkey})
const listUri = starterPack?.list?.uri
const {
data: listItems,
isLoading: isLoadingProfiles,
isError: isErrorProfiles,
} = useAllListMembersQuery(listUri)
const {
data: profile,
isLoading: isLoadingProfile,
isError: isErrorProfile,
} = useProfileQuery({did: profileDid})
const isEdit = Boolean(rkey)
const isReady =
(!isEdit || (isEdit && starterPack && listItems)) &&
profile &&
moderationOpts
if (!isReady) {
return (
<Layout.Screen>
<ListMaybePlaceholder
isLoading={
isLoadingStarterPack || isLoadingProfiles || isLoadingProfile
}
isError={isErrorStarterPack || isErrorProfiles || isErrorProfile}
errorMessage={_(msg`That starter pack could not be found.`)}
/>
</Layout.Screen>
)
} else if (isEdit && starterPack?.creator.did !== currentAccount?.did) {
return (
<Layout.Screen>
<ListMaybePlaceholder
isLoading={false}
isError={true}
errorMessage={_(msg`That starter pack could not be found.`)}
/>
</Layout.Screen>
)
}
return (
<Layout.Screen
minimalShell
testID="starterPackWizardScreen"
style={web([{minHeight: 0}, a.flex_1])}>
<Provider
starterPack={starterPack}
listItems={listItems}
targetProfile={profile}>
<WizardInner
currentStarterPack={starterPack}
currentListItems={listItems}
profile={profile}
moderationOpts={moderationOpts}
fromDialog={fromDialog}
onSuccess={onSuccess}
/>
</Provider>
</Layout.Screen>
)
}
function WizardInner({
currentStarterPack,
currentListItems,
profile,
moderationOpts,
fromDialog,
onSuccess,
}: {
currentStarterPack?: AppBskyGraphDefs.StarterPackView
currentListItems?: AppBskyGraphDefs.ListItemView[]
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
fromDialog?: boolean
onSuccess?: () => void
}) {
const navigation = useNavigation<NavigationProp>()
const ax = useAnalytics()
const {_} = useLingui()
const [state, dispatch] = useWizardState()
const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({
did: currentAccount?.did,
staleTime: 0,
})
const parsed = parseStarterPackUri(currentStarterPack?.uri)
useEffect(() => {
navigation.setOptions({
gestureEnabled: false,
})
}, [navigation])
const getDefaultName = () => {
const displayName = createSanitizedDisplayName(currentProfile!, true)
return _(msg`${displayName}'s Starter Pack`).slice(0, 50)
}
const wizardUiStrings: Record<
WizardStep,
{header: string; nextBtn: string; subtitle?: string}
> = {
Details: {
header: _(msg`Starter Pack`),
nextBtn: _(msg`Next`),
},
Profiles: {
header: _(msg`Choose People`),
nextBtn: _(msg`Next`),
},
Feeds: {
header: _(msg`Choose Feeds`),
nextBtn: state.feeds.length === 0 ? _(msg`Skip`) : _(msg`Finish`),
},
}
const currUiStrings = wizardUiStrings[state.currentStep]
const onSuccessCreate = (data: {uri: string; cid: string}) => {
const rkey = new AtUri(data.uri).rkey
ax.metric('starterPack:create', {
setName: state.name != null,
setDescription: state.description != null,
profilesCount: state.profiles.length,
feedsCount: state.feeds.length,
})
Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)])
dispatch({type: 'SetProcessing', processing: false})
if (fromDialog) {
navigation.goBack()
onSuccess?.()
} else {
navigation.replace('StarterPack', {
name: profile.handle,
rkey,
new: true,
})
}
}
const onSuccessEdit = () => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.replace('StarterPack', {
name: currentAccount!.handle,
rkey: parsed!.rkey,
})
}
}
const {mutate: createStarterPack} = useCreateStarterPackMutation({
onSuccess: onSuccessCreate,
onError: e => {
logger.error('Failed to create starter pack', {safeMessage: e})
dispatch({type: 'SetProcessing', processing: false})
Toast.show(_(msg`Failed to create starter pack`), {
type: 'error',
})
},
})
const {mutate: editStarterPack} = useEditStarterPackMutation({
onSuccess: onSuccessEdit,
onError: e => {
logger.error('Failed to edit starter pack', {safeMessage: e})
dispatch({type: 'SetProcessing', processing: false})
Toast.show(_(msg`Failed to create starter pack`), {
type: 'error',
})
},
})
const submit = async () => {
dispatch({type: 'SetProcessing', processing: true})
if (currentStarterPack && currentListItems) {
editStarterPack({
name: state.name?.trim() || getDefaultName(),
description: state.description?.trim(),
profiles: state.profiles,
feeds: state.feeds,
currentStarterPack: currentStarterPack,
currentListItems: currentListItems,
})
} else {
createStarterPack({
name: state.name?.trim() || getDefaultName(),
description: state.description?.trim(),
profiles: state.profiles,
feeds: state.feeds,
})
}
}
const onNext = () => {
if (state.currentStep === 'Feeds') {
submit()
return
}
const keyboardVisible = Keyboard.isVisible()
Keyboard.dismiss()
setTimeout(
() => {
dispatch({type: 'Next'})
},
keyboardVisible ? 16 : 0,
)
}
const items = state.currentStep === 'Profiles' ? state.profiles : state.feeds
const isEditEnabled =
(state.currentStep === 'Profiles' && items.length > 1) ||
(state.currentStep === 'Feeds' && items.length > 0)
const editDialogControl = useDialogControl()
return (
<Layout.Center style={[a.flex_1]}>
<Layout.Header.Outer>
<Layout.Header.BackButton
label={_(msg`Back`)}
accessibilityHint={_(msg`Returns to the previous step`)}
onPress={evt => {
if (state.currentStep !== 'Details') {
evt.preventDefault()
dispatch({type: 'Back'})
}
}}
/>
<Layout.Header.Content align="left">
<Layout.Header.TitleText>
{currUiStrings.header}
</Layout.Header.TitleText>
</Layout.Header.Content>
{isEditEnabled ? (
<Button
label={_(msg`Edit`)}
color="secondary"
size="small"
onPress={editDialogControl.open}>
<ButtonText>
<Trans>Edit</Trans>
</ButtonText>
</Button>
) : (
<Layout.Header.Slot />
)}
</Layout.Header.Outer>
<Container>
{state.currentStep === 'Details' ? (
<StepDetails />
) : state.currentStep === 'Profiles' ? (
<StepProfiles moderationOpts={moderationOpts} />
) : state.currentStep === 'Feeds' ? (
<StepFeeds moderationOpts={moderationOpts} />
) : null}
</Container>
{state.currentStep !== 'Details' && (
<Footer onNext={onNext} nextBtnText={currUiStrings.nextBtn} />
)}
<WizardEditListDialog
control={editDialogControl}
state={state}
dispatch={dispatch}
moderationOpts={moderationOpts}
profile={profile}
/>
</Layout.Center>
)
}
function Container({children}: {children: React.ReactNode}) {
const {_} = useLingui()
const [state, dispatch] = useWizardState()
if (state.currentStep === 'Profiles' || state.currentStep === 'Feeds') {
return <View style={[a.flex_1]}>{children}</View>
}
return (
<KeyboardAwareScrollView
style={[a.flex_1]}
keyboardShouldPersistTaps="handled">
{children}
{state.currentStep === 'Details' && (
<>
<Button
label={_(msg`Next`)}
variant="solid"
color="primary"
size="large"
style={[a.mx_xl, a.mb_lg, {marginTop: 35}]}
onPress={() => dispatch({type: 'Next'})}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
</Button>
</>
)}
</KeyboardAwareScrollView>
)
}
function Footer({
onNext,
nextBtnText,
}: {
onNext: () => void
nextBtnText: string
}) {
const t = useTheme()
const [state] = useWizardState()
const {bottom: bottomInset} = useSafeAreaInsets()
const {currentAccount} = useSession()
const items = state.currentStep === 'Profiles' ? state.profiles : state.feeds
const minimumItems = state.currentStep === 'Profiles' ? 8 : 0
const textStyles = [a.text_md]
return (
<View
style={[
a.border_t,
a.align_center,
a.px_lg,
a.pt_xl,
a.gap_md,
t.atoms.bg,
t.atoms.border_contrast_medium,
{
paddingBottom: a.pb_lg.paddingBottom + bottomInset,
},
IS_NATIVE && [
a.border_l,
a.border_r,
t.atoms.shadow_md,
{
borderTopLeftRadius: 14,
borderTopRightRadius: 14,
},
],
]}>
{items.length > minimumItems && (
<View style={[a.absolute, {right: 14, top: 31}]}>
<Text style={[a.font_semi_bold]}>
{items.length}/
{state.currentStep === 'Profiles' ? STARTER_PACK_MAX_SIZE : 3}
</Text>
</View>
)}
<View style={[a.flex_row]}>
{items.slice(0, 6).map((p, index) => (
<View
key={index}
style={[
a.rounded_full,
{
borderWidth: 0.5,
borderColor: t.atoms.bg.backgroundColor,
},
state.currentStep === 'Profiles'
? {zIndex: 1 - index, marginLeft: index > 0 ? -8 : 0}
: {marginRight: 4},
]}>
<UserAvatar
avatar={p.avatar}
size={32}
type={state.currentStep === 'Profiles' ? 'user' : 'algo'}
/>
</View>
))}
</View>
{
state.currentStep === 'Profiles' ? (
<Text style={[a.text_center, textStyles]}>
{
items.length < 2 ? (
currentAccount?.did === items[0].did ? (
<Trans>
It's just you right now! Add more people to your starter
pack by searching above.
</Trans>
) : (
<Trans>
It's just{' '}
<Text style={[a.font_semi_bold, textStyles]} emoji>
{getName(items[0])}{' '}
</Text>
right now! Add more people to your starter pack by searching
above.
</Trans>
)
) : items.length === 2 ? (
currentAccount?.did === items[0].did ? (
<Trans>
<Text style={[a.font_semi_bold, textStyles]}>You</Text> and
<Text> </Text>
<Text style={[a.font_semi_bold, textStyles]} emoji>
{getName(items[1] /* [0] is self, skip it */)}{' '}
</Text>
are included in your starter pack
</Trans>
) : (
<Trans>
<Text style={[a.font_semi_bold, textStyles]}>
{getName(items[0])}
</Text>{' '}
and
<Text> </Text>
<Text style={[a.font_semi_bold, textStyles]} emoji>
{getName(items[1] /* [0] is self, skip it */)}{' '}
</Text>
are included in your starter pack
</Trans>
)
) : items.length > 2 ? (
<Trans context="profiles">
<Text style={[a.font_semi_bold, textStyles]} emoji>
{getName(items[1] /* [0] is self, skip it */)},{' '}
</Text>
<Text style={[a.font_semi_bold, textStyles]} emoji>
{getName(items[2])},{' '}
</Text>
and{' '}
<Plural
value={items.length - 2}
one="# other"
other="# others"
/>{' '}
are included in your starter pack
</Trans>
) : null /* Should not happen. */
}
</Text>
) : state.currentStep === 'Feeds' ? (
items.length === 0 ? (
<View style={[a.gap_sm]}>
<Text style={[a.font_semi_bold, a.text_center, textStyles]}>
<Trans>Add some feeds to your starter pack!</Trans>
</Text>
<Text style={[a.text_center, textStyles]}>
<Trans>
Search for feeds that you want to suggest to others.
</Trans>
</Text>
</View>
) : (
<Text style={[a.text_center, textStyles]}>
{
items.length === 1 ? (
<Trans>
<Text style={[a.font_semi_bold, textStyles]} emoji>
{getName(items[0])}
</Text>{' '}
is included in your starter pack
</Trans>
) : items.length === 2 ? (
<Trans>
<Text style={[a.font_semi_bold, textStyles]} emoji>
{getName(items[0])}
</Text>{' '}
and
<Text> </Text>
<Text style={[a.font_semi_bold, textStyles]} emoji>
{getName(items[1])}{' '}
</Text>
are included in your starter pack
</Trans>
) : items.length > 2 ? (
<Trans context="feeds">
<Text style={[a.font_semi_bold, textStyles]} emoji>
{getName(items[0])},{' '}
</Text>
<Text style={[a.font_semi_bold, textStyles]} emoji>
{getName(items[1])},{' '}
</Text>
and{' '}
<Plural
value={items.length - 2}
one="# other"
other="# others"
/>{' '}
are included in your starter pack
</Trans>
) : null /* Should not happen. */
}
</Text>
)
) : null /* Should not happen. */
}
<View
style={[
a.w_full,
a.align_center,
a.gap_2xl,
IS_NATIVE ? a.mt_sm : a.mt_md,
]}>
{state.currentStep === 'Profiles' && items.length < 8 && (
<Text
style={[
a.font_semi_bold,
textStyles,
t.atoms.text_contrast_medium,
]}>
<Trans>Add {8 - items.length} more to continue</Trans>
</Text>
)}
<Button
label={nextBtnText}
style={[a.w_full, a.py_md, a.px_2xl]}
color="primary"
size="large"
onPress={onNext}
disabled={
!state.canNext ||
state.processing ||
(state.currentStep === 'Profiles' && items.length < 8)
}>
<ButtonText>{nextBtnText}</ButtonText>
{state.processing && <ButtonIcon icon={Loader} />}
</Button>
</View>
</View>
)
}
function getName(
item: bsky.profile.AnyProfileView | AppBskyFeedDefs.GeneratorView,
) {
if (typeof item.displayName === 'string') {
return enforceLen(sanitizeDisplayName(item.displayName), 28, true)
} else if ('handle' in item && typeof item.handle === 'string') {
return enforceLen(sanitizeHandle(item.handle), 28, true)
}
return ''
}