diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx
index de19b0bce8..73aee28f4e 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 0000000000..efd1577233
--- /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 b1db5caa64..6eb5cb609a 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 8256349dfa..b918e8baf3 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,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,
+ })
+ }
}
}
diff --git a/src/state/queries/actor-starter-packs.ts b/src/state/queries/actor-starter-packs.ts
index 670544dfea..bde7197437 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 879bf22f98..569823da64 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 && (
+
+ )}
+