starter pack dialog flow from profileMenu

This commit is contained in:
Chenyu Huang
2025-08-08 15:33:45 -07:00
parent cced762a7f
commit 7182cd3d5e
6 changed files with 487 additions and 19 deletions
@@ -180,7 +180,7 @@ function CreateAnother() {
color="secondary"
size="small"
style={[a.self_center]}
onPress={() => navigation.navigate('StarterPackWizard')}>
onPress={() => navigation.navigate('StarterPackWizard', {})}>
<ButtonText>
<Trans>Create another</Trans>
</ButtonText>
@@ -238,7 +238,7 @@ function Empty() {
],
})
const navToWizard = useCallback(() => {
navigation.navigate('StarterPackWizard')
navigation.navigate('StarterPackWizard', {})
}, [navigation])
const wrappedNavToWizard = requireEmailVerification(navToWizard, {
instructions: [
@@ -322,7 +322,7 @@ function Empty() {
color="secondary"
cta={_(msg`Let me choose`)}
onPress={() => {
navigation.navigate('StarterPackWizard')
navigation.navigate('StarterPackWizard', {})
}}
/>
</Prompt.Actions>
@@ -0,0 +1,388 @@
import React from 'react'
import {View} from 'react-native'
import {
type AppBskyGraphGetStarterPacksWithMembership,
AppBskyGraphStarterpack,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {type NavigationProp} from '#/lib/routes/types'
import {
RQKEY_WITH_MEMBERSHIP,
useActorStarterPacksWithMembershipsQuery,
} from '#/state/queries/actor-starter-packs'
import {
useListMembershipAddMutation,
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships'
import {List} from '#/view/com/util/List'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import {PlusLarge_Stroke2_Corner0_Rounded} from '../icons/Plus'
import {StarterPack} from '../icons/StarterPack'
import {TimesLarge_Stroke2_Corner0_Rounded} from '../icons/Times'
type StarterPackWithMembership =
AppBskyGraphGetStarterPacksWithMembership.StarterPackWithMembership
// Simple module-level state for dialog coordination
let dialogCallbacks: {
onSuccess?: () => void
} = {}
export function notifyDialogSuccess() {
if (dialogCallbacks.onSuccess) {
dialogCallbacks.onSuccess()
}
}
export type StarterPackDialogProps = {
control: Dialog.DialogControlProps
accountDid: string
targetDid: string
enabled?: boolean
}
export function StarterPackDialog({
control,
accountDid: _accountDid,
targetDid,
enabled,
}: StarterPackDialogProps) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const requireEmailVerification = useRequireEmailVerification()
React.useEffect(() => {
dialogCallbacks.onSuccess = () => {
if (!control.isOpen) {
control.open()
}
}
}, [control])
const navToWizard = React.useCallback(() => {
control.close()
navigation.navigate('StarterPackWizard', {fromDialog: true})
}, [navigation, control])
const wrappedNavToWizard = requireEmailVerification(navToWizard, {
instructions: [
<Trans key="nav">
Before creating a starter pack, you must first verify your email.
</Trans>,
],
})
const onClose = React.useCallback(() => {
// setCurrentView('initial')
control.close()
}, [control])
const t = useTheme()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.Inner label={_(msg`Add to starter packs`)} style={[a.w_full]}>
<View>
<View
style={[
{justifyContent: 'space-between', flexDirection: 'row'},
a.my_lg,
]}>
<Text style={[a.text_lg, a.font_bold]}>
<Trans>Add to starter packs</Trans>
</Text>
<TimesLarge_Stroke2_Corner0_Rounded
onPress={onClose}
fill={t.atoms.text_contrast_medium.color}
/>
</View>
<StarterPackList
onStartWizard={wrappedNavToWizard}
targetDid={targetDid}
enabled={enabled}
/>
</View>
</Dialog.Inner>
</Dialog.Outer>
)
}
function Empty({onStartWizard}: {onStartWizard: () => void}) {
const {_} = useLingui()
const t = useTheme()
return (
<View
style={[a.align_center, a.gap_2xl, {paddingTop: 64, paddingBottom: 64}]}>
<View style={[a.gap_xs, a.align_center]}>
<StarterPack
width={48}
fill={t.atoms.border_contrast_medium.borderColor}
/>
<Text style={[a.text_center]}>
<Trans>You have no starter packs.</Trans>
</Text>
</View>
<Button
label={_(msg`Create starter pack`)}
color="secondary_inverted"
size="small"
onPress={onStartWizard}>
<ButtonText>
<Trans>Create</Trans>
</ButtonText>
<ButtonIcon icon={PlusLarge_Stroke2_Corner0_Rounded} />
</Button>
</View>
)
}
function StarterPackList({
onStartWizard,
targetDid,
enabled,
}: {
onStartWizard: () => void
targetDid: string
enabled?: boolean
}) {
const {_} = useLingui()
const {
data,
refetch,
isError,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useActorStarterPacksWithMembershipsQuery({did: targetDid, enabled})
const membershipItems =
data?.pages.flatMap(page => page.starterPacksWithMembership) || []
const _onRefresh = React.useCallback(async () => {
try {
await refetch()
} catch (err) {
// Error handling is optional since this is just a refresh
}
}, [refetch])
const _onEndReached = React.useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
// Error handling is optional since this is just pagination
}
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
const renderItem = React.useCallback(
({item}: {item: StarterPackWithMembership}) => (
<StarterPackItem starterPackWithMembership={item} targetDid={targetDid} />
),
[targetDid],
)
const ListHeaderComponent = React.useCallback(
() => (
<>
<View style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}>
<Text style={[a.text_md, a.font_bold]}>
<Trans>New starter pack</Trans>
</Text>
<Button
label={_(msg`Create starter pack`)}
color="secondary_inverted"
size="small"
onPress={onStartWizard}>
<ButtonText>
<Trans>Create</Trans>
</ButtonText>
<ButtonIcon icon={PlusLarge_Stroke2_Corner0_Rounded} />
</Button>
</View>
<Divider />
</>
),
[_, onStartWizard],
)
return (
<List
data={membershipItems}
renderItem={renderItem}
keyExtractor={(item: StarterPackWithMembership, index: number) =>
item.starterPack.uri || index.toString()
}
refreshing={false}
onRefresh={_onRefresh}
onEndReached={_onEndReached}
onEndReachedThreshold={0.1}
ListHeaderComponent={
membershipItems.length > 0 ? ListHeaderComponent : null
}
ListEmptyComponent={<Empty onStartWizard={onStartWizard} />}
/>
)
}
function StarterPackItem({
starterPackWithMembership,
targetDid,
}: {
starterPackWithMembership: StarterPackWithMembership
targetDid: string
}) {
const {_} = useLingui()
const t = useTheme()
const queryClient = useQueryClient()
const [isUpdating, setIsUpdating] = React.useState(false)
const starterPack = starterPackWithMembership.starterPack
const isInPack = !!starterPackWithMembership.listItem
console.log('StarterPackItem render. 111', {
starterPackWithMembership: starterPackWithMembership.listItem?.subject,
})
console.log('StarterPackItem render', {
starterPackWithMembership,
})
const {mutateAsync: addMembership} = useListMembershipAddMutation({
onSuccess: () => {
Toast.show(_(msg`Added to starter pack`))
},
onError: () => {
Toast.show(_(msg`Failed to add to starter pack`), 'xmark')
},
})
const {mutateAsync: removeMembership} = useListMembershipRemoveMutation({
onSuccess: () => {
Toast.show(_(msg`Removed from starter pack`))
},
onError: () => {
Toast.show(_(msg`Failed to remove from starter pack`), 'xmark')
},
})
const handleToggleMembership = async () => {
if (!starterPack.list?.uri || isUpdating) return
const listUri = starterPack.list.uri
setIsUpdating(true)
try {
if (!isInPack) {
await addMembership({
listUri: listUri,
actorDid: targetDid,
})
} else {
if (!starterPackWithMembership.listItem?.uri) {
console.error('Cannot remove: missing membership URI')
return
}
await removeMembership({
listUri: listUri,
actorDid: targetDid,
membershipUri: starterPackWithMembership.listItem.uri,
})
}
await Promise.all([
queryClient.invalidateQueries({
queryKey: RQKEY_WITH_MEMBERSHIP(targetDid),
}),
])
} catch (error) {
console.error('Failed to toggle membership:', error)
} finally {
setIsUpdating(false)
}
}
const {record} = starterPack
if (
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
record,
AppBskyGraphStarterpack.isRecord,
)
) {
return null
}
return (
<View style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}>
<View>
<Text emoji style={[a.text_md, a.font_bold]} numberOfLines={1}>
{record.name}
</Text>
<View style={[a.flex_row, a.align_center, a.mt_xs]}>
{starterPack.listItemsSample &&
starterPack.listItemsSample.length > 0 && (
<>
{starterPack.listItemsSample?.slice(0, 4).map((p, index) => (
<UserAvatar
key={p.subject.did}
avatar={p.subject.avatar}
size={32}
type={'user'}
style={[
{
zIndex: 1 - index,
marginLeft: index > 0 ? -2 : 0,
borderWidth: 0.5,
borderColor: t.atoms.bg.backgroundColor,
},
]}
/>
))}
{starterPack.list?.listItemCount &&
starterPack.list.listItemCount > 4 && (
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
a.ml_xs,
]}>
{`+${starterPack.list.listItemCount - 4} more`}
</Text>
)}
</>
)}
</View>
</View>
<Button
label={isInPack ? _(msg`Remove`) : _(msg`Add`)}
color={isInPack ? 'secondary' : 'primary'}
size="tiny"
disabled={isUpdating}
onPress={handleToggleMembership}>
<ButtonText>
{isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
</ButtonText>
</Button>
</View>
)
}
+1 -1
View File
@@ -79,7 +79,7 @@ export type CommonNavigatorParams = {
Start: {name: string; rkey: string}
StarterPack: {name: string; rkey: string; new?: boolean}
StarterPackShort: {code: string}
StarterPackWizard: undefined
StarterPackWizard: {fromDialog?: boolean}
StarterPackEdit: {rkey?: string}
VideoFeed: VideoFeedSourceContext
}
+32 -11
View File
@@ -54,6 +54,7 @@ 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 {notifyDialogSuccess} from '#/components/dialogs/StarterPackDialog'
import * as Layout from '#/components/Layout'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Loader} from '#/components/Loader'
@@ -68,7 +69,9 @@ export function Wizard({
CommonNavigatorParams,
'StarterPackEdit' | 'StarterPackWizard'
>) {
const {rkey} = route.params ?? {}
const params = route.params ?? {}
const rkey = 'rkey' in params ? params.rkey : undefined
const fromDialog = 'fromDialog' in params ? params.fromDialog : false
const {currentAccount} = useSession()
const moderationOpts = useModerationOpts()
@@ -133,6 +136,7 @@ export function Wizard({
currentListItems={listItems}
profile={profile}
moderationOpts={moderationOpts}
fromDialog={fromDialog}
/>
</Provider>
</Layout.Screen>
@@ -144,17 +148,20 @@ function WizardInner({
currentListItems,
profile,
moderationOpts,
fromDialog,
}: {
currentStarterPack?: AppBskyGraphDefs.StarterPackView
currentListItems?: AppBskyGraphDefs.ListItemView[]
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
fromDialog?: boolean
}) {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const [state, dispatch] = useWizardState()
const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({
did: currentAccount?.did,
staleTime: 0,
@@ -213,21 +220,35 @@ function WizardInner({
})
Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)])
dispatch({type: 'SetProcessing', processing: false})
navigation.replace('StarterPack', {
name: currentAccount!.handle,
rkey,
new: true,
})
// If launched from ProfileMenu dialog, notify the dialog and go back
if (fromDialog) {
navigation.goBack()
notifyDialogSuccess()
} else {
// Original behavior for other entry points
navigation.replace('StarterPack', {
name: currentAccount!.handle,
rkey,
new: true,
})
}
}
const onSuccessEdit = () => {
if (navigation.canGoBack()) {
// If launched from ProfileMenu dialog, go back to stay on profile page
if (fromDialog) {
navigation.goBack()
} else {
navigation.replace('StarterPack', {
name: currentAccount!.handle,
rkey: parsed!.rkey,
})
// Original behavior for other entry points
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.replace('StarterPack', {
name: currentAccount!.handle,
rkey: parsed!.rkey,
})
}
}
}
+43 -4
View File
@@ -1,15 +1,23 @@
import {AppBskyGraphGetActorStarterPacks} from '@atproto/api'
import {
InfiniteData,
QueryClient,
QueryKey,
type AppBskyGraphGetActorStarterPacks,
type AppBskyGraphGetStarterPacksWithMembership,
} from '@atproto/api'
import {
type InfiniteData,
type QueryClient,
type QueryKey,
useInfiniteQuery,
} from '@tanstack/react-query'
import {useAgent} from '#/state/session'
export const RQKEY_ROOT = 'actor-starter-packs'
export const RQKEY_WITH_MEMBERSHIP_ROOT = 'actor-starter-packs-with-membership'
export const RQKEY = (did?: string) => [RQKEY_ROOT, did]
export const RQKEY_WITH_MEMBERSHIP = (did?: string) => [
RQKEY_WITH_MEMBERSHIP_ROOT,
did,
]
export function useActorStarterPacksQuery({
did,
@@ -42,6 +50,37 @@ export function useActorStarterPacksQuery({
})
}
export function useActorStarterPacksWithMembershipsQuery({
did,
enabled = true,
}: {
did?: string
enabled?: boolean
}) {
const agent = useAgent()
return useInfiniteQuery<
AppBskyGraphGetStarterPacksWithMembership.OutputSchema,
Error,
InfiniteData<AppBskyGraphGetStarterPacksWithMembership.OutputSchema>,
QueryKey,
string | undefined
>({
queryKey: RQKEY_WITH_MEMBERSHIP(did),
queryFn: async ({pageParam}: {pageParam?: string}) => {
const res = await agent.app.bsky.graph.getStarterPacksWithMembership({
actor: did!,
limit: 10,
cursor: pageParam,
})
return res.data
},
enabled: Boolean(did) && enabled,
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
})
}
export async function invalidateActorStarterPacksQuery({
queryClient,
did,
+20
View File
@@ -27,6 +27,7 @@ import {EventStopper} from '#/view/com/util/EventStopper'
import * as Toast from '#/view/com/util/Toast'
import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog'
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
@@ -45,6 +46,7 @@ import {
} from '#/components/icons/Person'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {StarterPack} from '#/components/icons/StarterPack'
import {EditLiveDialog} from '#/components/live/EditLiveDialog'
import {GoLiveDialog} from '#/components/live/GoLiveDialog'
import * as Menu from '#/components/Menu'
@@ -88,6 +90,7 @@ let ProfileMenu = ({
const blockPromptControl = Prompt.usePromptControl()
const loggedOutWarningPromptControl = Prompt.usePromptControl()
const goLiveDialogControl = useDialogControl()
const addToStarterPacksDialogControl = useDialogControl()
const showLoggedOutWarning = React.useMemo(() => {
return (
@@ -300,6 +303,15 @@ let ProfileMenu = ({
)}
</>
)}
<Menu.Item
testID="profileHeaderDropdownStarterPackAddRemoveBtn"
label={_(msg`Add to starter packs`)}
onPress={addToStarterPacksDialogControl.open}>
<Menu.ItemText>
<Trans>Add to starter packs</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={StarterPack} />
</Menu.Item>
<Menu.Item
testID="profileHeaderDropdownListAddRemoveBtn"
label={_(msg`Add to lists`)}
@@ -440,6 +452,14 @@ let ProfileMenu = ({
</Menu.Outer>
</Menu.Root>
{currentAccount && (
<StarterPackDialog
control={addToStarterPacksDialogControl}
accountDid={currentAccount.did}
targetDid={profile.did}
/>
)}
<ReportDialog
control={reportDialogControl}
subject={{