From 7182cd3d5e157d7ad80f2e5c4a458730c46939a0 Mon Sep 17 00:00:00 2001 From: Chenyu Huang Date: Fri, 8 Aug 2025 15:33:45 -0700 Subject: starter pack dialog flow from profileMenu --- src/components/StarterPack/ProfileStarterPacks.tsx | 6 +- src/components/dialogs/StarterPackDialog.tsx | 388 +++++++++++++++++++++ src/lib/routes/types.ts | 2 +- src/screens/StarterPack/Wizard/index.tsx | 41 ++- src/state/queries/actor-starter-packs.ts | 47 ++- src/view/com/profile/ProfileMenu.tsx | 20 ++ 6 files changed, 486 insertions(+), 18 deletions(-) create mode 100644 src/components/dialogs/StarterPackDialog.tsx (limited to 'src') diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx index de19b0bce..73aee28f4 100644 --- a/src/components/StarterPack/ProfileStarterPacks.tsx +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -180,7 +180,7 @@ function CreateAnother() { color="secondary" size="small" style={[a.self_center]} - onPress={() => navigation.navigate('StarterPackWizard')}> + onPress={() => navigation.navigate('StarterPackWizard', {})}> Create another @@ -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', {}) }} /> diff --git a/src/components/dialogs/StarterPackDialog.tsx b/src/components/dialogs/StarterPackDialog.tsx new file mode 100644 index 000000000..efd157723 --- /dev/null +++ b/src/components/dialogs/StarterPackDialog.tsx @@ -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() + 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: [ + + Before creating a starter pack, you must first verify your email. + , + ], + }) + + const onClose = React.useCallback(() => { + // setCurrentView('initial') + control.close() + }, [control]) + + const t = useTheme() + + return ( + + + + + + + Add to starter packs + + + + + + + + + ) +} + +function Empty({onStartWizard}: {onStartWizard: () => void}) { + const {_} = useLingui() + const t = useTheme() + + return ( + + + + + You have no starter packs. + + + + + + ) +} + +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}) => ( + + ), + [targetDid], + ) + + const ListHeaderComponent = React.useCallback( + () => ( + <> + + + New starter pack + + + + + + ), + [_, onStartWizard], + ) + + return ( + + item.starterPack.uri || index.toString() + } + refreshing={false} + onRefresh={_onRefresh} + onEndReached={_onEndReached} + onEndReachedThreshold={0.1} + ListHeaderComponent={ + membershipItems.length > 0 ? ListHeaderComponent : null + } + ListEmptyComponent={} + /> + ) +} + +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( + record, + AppBskyGraphStarterpack.isRecord, + ) + ) { + return null + } + + return ( + + + + {record.name} + + + + {starterPack.listItemsSample && + starterPack.listItemsSample.length > 0 && ( + <> + {starterPack.listItemsSample?.slice(0, 4).map((p, index) => ( + 0 ? -2 : 0, + borderWidth: 0.5, + borderColor: t.atoms.bg.backgroundColor, + }, + ]} + /> + ))} + + {starterPack.list?.listItemCount && + starterPack.list.listItemCount > 4 && ( + + {`+${starterPack.list.listItemCount - 4} more`} + + )} + + )} + + + + + + ) +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index b1db5caa6..6eb5cb609 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -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 } diff --git a/src/screens/StarterPack/Wizard/index.tsx b/src/screens/StarterPack/Wizard/index.tsx index 8256349df..b918e8baf 100644 --- a/src/screens/StarterPack/Wizard/index.tsx +++ b/src/screens/StarterPack/Wizard/index.tsx @@ -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} /> @@ -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() const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() const [state, dispatch] = useWizardState() const {currentAccount} = useSession() + const {data: currentProfile} = useProfileQuery({ did: currentAccount?.did, staleTime: 0, @@ -213,24 +220,38 @@ function WizardInner({ }) Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)]) dispatch({type: 'SetProcessing', processing: false}) - navigation.replace('StarterPack', { - name: currentAccount!.handle, - rkey, - new: true, - }) - } - const onSuccessEdit = () => { - if (navigation.canGoBack()) { + // 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: parsed!.rkey, + rkey, + new: true, }) } } + const onSuccessEdit = () => { + // If launched from ProfileMenu dialog, go back to stay on profile page + if (fromDialog) { + navigation.goBack() + } else { + // Original behavior for other entry points + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.replace('StarterPack', { + name: currentAccount!.handle, + rkey: parsed!.rkey, + }) + } + } + } + const {mutate: createStarterPack} = useCreateStarterPackMutation({ onSuccess: onSuccessCreate, onError: e => { diff --git a/src/state/queries/actor-starter-packs.ts b/src/state/queries/actor-starter-packs.ts index 670544dfe..bde719743 100644 --- a/src/state/queries/actor-starter-packs.ts +++ b/src/state/queries/actor-starter-packs.ts @@ -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, + 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, diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 879bf22f9..569823da6 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -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 = ({ )} )} + + + Add to starter packs + + + + {currentAccount && ( + + )} +