about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorChenyu Huang <itschenyu@gmail.com>2025-08-08 15:33:45 -0700
committerChenyu Huang <itschenyu@gmail.com>2025-08-16 19:45:43 -0700
commit7182cd3d5e157d7ad80f2e5c4a458730c46939a0 (patch)
tree55cc28836cd05c5fb0b1d2784d456faa28c9911e /src
parentcced762a7fb7a2729b63922abc34ae5406a58bce (diff)
downloadvoidsky-7182cd3d5e157d7ad80f2e5c4a458730c46939a0.tar.zst
starter pack dialog flow from profileMenu
Diffstat (limited to 'src')
-rw-r--r--src/components/StarterPack/ProfileStarterPacks.tsx6
-rw-r--r--src/components/dialogs/StarterPackDialog.tsx388
-rw-r--r--src/lib/routes/types.ts2
-rw-r--r--src/screens/StarterPack/Wizard/index.tsx41
-rw-r--r--src/state/queries/actor-starter-packs.ts47
-rw-r--r--src/view/com/profile/ProfileMenu.tsx20
6 files changed, 486 insertions, 18 deletions
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', {})}>
         <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>
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<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>
+  )
+}
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}
         />
       </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,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<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,
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 (
@@ -301,6 +304,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`)}
                   onPress={onPressAddRemoveLists}>
@@ -440,6 +452,14 @@ let ProfileMenu = ({
         </Menu.Outer>
       </Menu.Root>
 
+      {currentAccount && (
+        <StarterPackDialog
+          control={addToStarterPacksDialogControl}
+          accountDid={currentAccount.did}
+          targetDid={profile.did}
+        />
+      )}
+
       <ReportDialog
         control={reportDialogControl}
         subject={{