An ATproto social media client -- with an independent Appview.

starter pack dialog flow from profileMenu

+486 -18
+3 -3
src/components/StarterPack/ProfileStarterPacks.tsx
··· 180 180 color="secondary" 181 181 size="small" 182 182 style={[a.self_center]} 183 - onPress={() => navigation.navigate('StarterPackWizard')}> 183 + onPress={() => navigation.navigate('StarterPackWizard', {})}> 184 184 <ButtonText> 185 185 <Trans>Create another</Trans> 186 186 </ButtonText> ··· 238 238 ], 239 239 }) 240 240 const navToWizard = useCallback(() => { 241 - navigation.navigate('StarterPackWizard') 241 + navigation.navigate('StarterPackWizard', {}) 242 242 }, [navigation]) 243 243 const wrappedNavToWizard = requireEmailVerification(navToWizard, { 244 244 instructions: [ ··· 322 322 color="secondary" 323 323 cta={_(msg`Let me choose`)} 324 324 onPress={() => { 325 - navigation.navigate('StarterPackWizard') 325 + navigation.navigate('StarterPackWizard', {}) 326 326 }} 327 327 /> 328 328 </Prompt.Actions>
+388
src/components/dialogs/StarterPackDialog.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import { 4 + type AppBskyGraphGetStarterPacksWithMembership, 5 + AppBskyGraphStarterpack, 6 + } from '@atproto/api' 7 + import {msg, Trans} from '@lingui/macro' 8 + import {useLingui} from '@lingui/react' 9 + import {useNavigation} from '@react-navigation/native' 10 + import {useQueryClient} from '@tanstack/react-query' 11 + 12 + import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 13 + import {type NavigationProp} from '#/lib/routes/types' 14 + import { 15 + RQKEY_WITH_MEMBERSHIP, 16 + useActorStarterPacksWithMembershipsQuery, 17 + } from '#/state/queries/actor-starter-packs' 18 + import { 19 + useListMembershipAddMutation, 20 + useListMembershipRemoveMutation, 21 + } from '#/state/queries/list-memberships' 22 + import {List} from '#/view/com/util/List' 23 + import * as Toast from '#/view/com/util/Toast' 24 + import {UserAvatar} from '#/view/com/util/UserAvatar' 25 + import {atoms as a, useTheme} from '#/alf' 26 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 27 + import * as Dialog from '#/components/Dialog' 28 + import {Divider} from '#/components/Divider' 29 + import {Text} from '#/components/Typography' 30 + import * as bsky from '#/types/bsky' 31 + import {PlusLarge_Stroke2_Corner0_Rounded} from '../icons/Plus' 32 + import {StarterPack} from '../icons/StarterPack' 33 + import {TimesLarge_Stroke2_Corner0_Rounded} from '../icons/Times' 34 + 35 + type StarterPackWithMembership = 36 + AppBskyGraphGetStarterPacksWithMembership.StarterPackWithMembership 37 + 38 + // Simple module-level state for dialog coordination 39 + let dialogCallbacks: { 40 + onSuccess?: () => void 41 + } = {} 42 + 43 + export function notifyDialogSuccess() { 44 + if (dialogCallbacks.onSuccess) { 45 + dialogCallbacks.onSuccess() 46 + } 47 + } 48 + 49 + export type StarterPackDialogProps = { 50 + control: Dialog.DialogControlProps 51 + accountDid: string 52 + targetDid: string 53 + enabled?: boolean 54 + } 55 + 56 + export function StarterPackDialog({ 57 + control, 58 + accountDid: _accountDid, 59 + targetDid, 60 + enabled, 61 + }: StarterPackDialogProps) { 62 + const {_} = useLingui() 63 + const navigation = useNavigation<NavigationProp>() 64 + const requireEmailVerification = useRequireEmailVerification() 65 + 66 + React.useEffect(() => { 67 + dialogCallbacks.onSuccess = () => { 68 + if (!control.isOpen) { 69 + control.open() 70 + } 71 + } 72 + }, [control]) 73 + 74 + const navToWizard = React.useCallback(() => { 75 + control.close() 76 + navigation.navigate('StarterPackWizard', {fromDialog: true}) 77 + }, [navigation, control]) 78 + 79 + const wrappedNavToWizard = requireEmailVerification(navToWizard, { 80 + instructions: [ 81 + <Trans key="nav"> 82 + Before creating a starter pack, you must first verify your email. 83 + </Trans>, 84 + ], 85 + }) 86 + 87 + const onClose = React.useCallback(() => { 88 + // setCurrentView('initial') 89 + control.close() 90 + }, [control]) 91 + 92 + const t = useTheme() 93 + 94 + return ( 95 + <Dialog.Outer control={control}> 96 + <Dialog.Handle /> 97 + <Dialog.Inner label={_(msg`Add to starter packs`)} style={[a.w_full]}> 98 + <View> 99 + <View 100 + style={[ 101 + {justifyContent: 'space-between', flexDirection: 'row'}, 102 + a.my_lg, 103 + ]}> 104 + <Text style={[a.text_lg, a.font_bold]}> 105 + <Trans>Add to starter packs</Trans> 106 + </Text> 107 + <TimesLarge_Stroke2_Corner0_Rounded 108 + onPress={onClose} 109 + fill={t.atoms.text_contrast_medium.color} 110 + /> 111 + </View> 112 + 113 + <StarterPackList 114 + onStartWizard={wrappedNavToWizard} 115 + targetDid={targetDid} 116 + enabled={enabled} 117 + /> 118 + </View> 119 + </Dialog.Inner> 120 + </Dialog.Outer> 121 + ) 122 + } 123 + 124 + function Empty({onStartWizard}: {onStartWizard: () => void}) { 125 + const {_} = useLingui() 126 + const t = useTheme() 127 + 128 + return ( 129 + <View 130 + style={[a.align_center, a.gap_2xl, {paddingTop: 64, paddingBottom: 64}]}> 131 + <View style={[a.gap_xs, a.align_center]}> 132 + <StarterPack 133 + width={48} 134 + fill={t.atoms.border_contrast_medium.borderColor} 135 + /> 136 + <Text style={[a.text_center]}> 137 + <Trans>You have no starter packs.</Trans> 138 + </Text> 139 + </View> 140 + 141 + <Button 142 + label={_(msg`Create starter pack`)} 143 + color="secondary_inverted" 144 + size="small" 145 + onPress={onStartWizard}> 146 + <ButtonText> 147 + <Trans>Create</Trans> 148 + </ButtonText> 149 + <ButtonIcon icon={PlusLarge_Stroke2_Corner0_Rounded} /> 150 + </Button> 151 + </View> 152 + ) 153 + } 154 + 155 + function StarterPackList({ 156 + onStartWizard, 157 + targetDid, 158 + enabled, 159 + }: { 160 + onStartWizard: () => void 161 + targetDid: string 162 + enabled?: boolean 163 + }) { 164 + const {_} = useLingui() 165 + 166 + const { 167 + data, 168 + refetch, 169 + isError, 170 + hasNextPage, 171 + isFetchingNextPage, 172 + fetchNextPage, 173 + } = useActorStarterPacksWithMembershipsQuery({did: targetDid, enabled}) 174 + 175 + const membershipItems = 176 + data?.pages.flatMap(page => page.starterPacksWithMembership) || [] 177 + 178 + const _onRefresh = React.useCallback(async () => { 179 + try { 180 + await refetch() 181 + } catch (err) { 182 + // Error handling is optional since this is just a refresh 183 + } 184 + }, [refetch]) 185 + 186 + const _onEndReached = React.useCallback(async () => { 187 + if (isFetchingNextPage || !hasNextPage || isError) return 188 + try { 189 + await fetchNextPage() 190 + } catch (err) { 191 + // Error handling is optional since this is just pagination 192 + } 193 + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 194 + 195 + const renderItem = React.useCallback( 196 + ({item}: {item: StarterPackWithMembership}) => ( 197 + <StarterPackItem starterPackWithMembership={item} targetDid={targetDid} /> 198 + ), 199 + [targetDid], 200 + ) 201 + 202 + const ListHeaderComponent = React.useCallback( 203 + () => ( 204 + <> 205 + <View style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}> 206 + <Text style={[a.text_md, a.font_bold]}> 207 + <Trans>New starter pack</Trans> 208 + </Text> 209 + <Button 210 + label={_(msg`Create starter pack`)} 211 + color="secondary_inverted" 212 + size="small" 213 + onPress={onStartWizard}> 214 + <ButtonText> 215 + <Trans>Create</Trans> 216 + </ButtonText> 217 + <ButtonIcon icon={PlusLarge_Stroke2_Corner0_Rounded} /> 218 + </Button> 219 + </View> 220 + <Divider /> 221 + </> 222 + ), 223 + [_, onStartWizard], 224 + ) 225 + 226 + return ( 227 + <List 228 + data={membershipItems} 229 + renderItem={renderItem} 230 + keyExtractor={(item: StarterPackWithMembership, index: number) => 231 + item.starterPack.uri || index.toString() 232 + } 233 + refreshing={false} 234 + onRefresh={_onRefresh} 235 + onEndReached={_onEndReached} 236 + onEndReachedThreshold={0.1} 237 + ListHeaderComponent={ 238 + membershipItems.length > 0 ? ListHeaderComponent : null 239 + } 240 + ListEmptyComponent={<Empty onStartWizard={onStartWizard} />} 241 + /> 242 + ) 243 + } 244 + 245 + function StarterPackItem({ 246 + starterPackWithMembership, 247 + targetDid, 248 + }: { 249 + starterPackWithMembership: StarterPackWithMembership 250 + targetDid: string 251 + }) { 252 + const {_} = useLingui() 253 + const t = useTheme() 254 + const queryClient = useQueryClient() 255 + const [isUpdating, setIsUpdating] = React.useState(false) 256 + 257 + const starterPack = starterPackWithMembership.starterPack 258 + const isInPack = !!starterPackWithMembership.listItem 259 + console.log('StarterPackItem render. 111', { 260 + starterPackWithMembership: starterPackWithMembership.listItem?.subject, 261 + }) 262 + 263 + console.log('StarterPackItem render', { 264 + starterPackWithMembership, 265 + }) 266 + 267 + const {mutateAsync: addMembership} = useListMembershipAddMutation({ 268 + onSuccess: () => { 269 + Toast.show(_(msg`Added to starter pack`)) 270 + }, 271 + onError: () => { 272 + Toast.show(_(msg`Failed to add to starter pack`), 'xmark') 273 + }, 274 + }) 275 + 276 + const {mutateAsync: removeMembership} = useListMembershipRemoveMutation({ 277 + onSuccess: () => { 278 + Toast.show(_(msg`Removed from starter pack`)) 279 + }, 280 + onError: () => { 281 + Toast.show(_(msg`Failed to remove from starter pack`), 'xmark') 282 + }, 283 + }) 284 + 285 + const handleToggleMembership = async () => { 286 + if (!starterPack.list?.uri || isUpdating) return 287 + 288 + const listUri = starterPack.list.uri 289 + setIsUpdating(true) 290 + 291 + try { 292 + if (!isInPack) { 293 + await addMembership({ 294 + listUri: listUri, 295 + actorDid: targetDid, 296 + }) 297 + } else { 298 + if (!starterPackWithMembership.listItem?.uri) { 299 + console.error('Cannot remove: missing membership URI') 300 + return 301 + } 302 + await removeMembership({ 303 + listUri: listUri, 304 + actorDid: targetDid, 305 + membershipUri: starterPackWithMembership.listItem.uri, 306 + }) 307 + } 308 + 309 + await Promise.all([ 310 + queryClient.invalidateQueries({ 311 + queryKey: RQKEY_WITH_MEMBERSHIP(targetDid), 312 + }), 313 + ]) 314 + } catch (error) { 315 + console.error('Failed to toggle membership:', error) 316 + } finally { 317 + setIsUpdating(false) 318 + } 319 + } 320 + 321 + const {record} = starterPack 322 + 323 + if ( 324 + !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>( 325 + record, 326 + AppBskyGraphStarterpack.isRecord, 327 + ) 328 + ) { 329 + return null 330 + } 331 + 332 + return ( 333 + <View style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}> 334 + <View> 335 + <Text emoji style={[a.text_md, a.font_bold]} numberOfLines={1}> 336 + {record.name} 337 + </Text> 338 + 339 + <View style={[a.flex_row, a.align_center, a.mt_xs]}> 340 + {starterPack.listItemsSample && 341 + starterPack.listItemsSample.length > 0 && ( 342 + <> 343 + {starterPack.listItemsSample?.slice(0, 4).map((p, index) => ( 344 + <UserAvatar 345 + key={p.subject.did} 346 + avatar={p.subject.avatar} 347 + size={32} 348 + type={'user'} 349 + style={[ 350 + { 351 + zIndex: 1 - index, 352 + marginLeft: index > 0 ? -2 : 0, 353 + borderWidth: 0.5, 354 + borderColor: t.atoms.bg.backgroundColor, 355 + }, 356 + ]} 357 + /> 358 + ))} 359 + 360 + {starterPack.list?.listItemCount && 361 + starterPack.list.listItemCount > 4 && ( 362 + <Text 363 + style={[ 364 + a.text_sm, 365 + t.atoms.text_contrast_medium, 366 + a.ml_xs, 367 + ]}> 368 + {`+${starterPack.list.listItemCount - 4} more`} 369 + </Text> 370 + )} 371 + </> 372 + )} 373 + </View> 374 + </View> 375 + 376 + <Button 377 + label={isInPack ? _(msg`Remove`) : _(msg`Add`)} 378 + color={isInPack ? 'secondary' : 'primary'} 379 + size="tiny" 380 + disabled={isUpdating} 381 + onPress={handleToggleMembership}> 382 + <ButtonText> 383 + {isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>} 384 + </ButtonText> 385 + </Button> 386 + </View> 387 + ) 388 + }
+1 -1
src/lib/routes/types.ts
··· 79 79 Start: {name: string; rkey: string} 80 80 StarterPack: {name: string; rkey: string; new?: boolean} 81 81 StarterPackShort: {code: string} 82 - StarterPackWizard: undefined 82 + StarterPackWizard: {fromDialog?: boolean} 83 83 StarterPackEdit: {rkey?: string} 84 84 VideoFeed: VideoFeedSourceContext 85 85 }
+31 -10
src/screens/StarterPack/Wizard/index.tsx
··· 54 54 import {atoms as a, useTheme, web} from '#/alf' 55 55 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 56 56 import {useDialogControl} from '#/components/Dialog' 57 + import {notifyDialogSuccess} from '#/components/dialogs/StarterPackDialog' 57 58 import * as Layout from '#/components/Layout' 58 59 import {ListMaybePlaceholder} from '#/components/Lists' 59 60 import {Loader} from '#/components/Loader' ··· 68 69 CommonNavigatorParams, 69 70 'StarterPackEdit' | 'StarterPackWizard' 70 71 >) { 71 - const {rkey} = route.params ?? {} 72 + const params = route.params ?? {} 73 + const rkey = 'rkey' in params ? params.rkey : undefined 74 + const fromDialog = 'fromDialog' in params ? params.fromDialog : false 72 75 const {currentAccount} = useSession() 73 76 const moderationOpts = useModerationOpts() 74 77 ··· 133 136 currentListItems={listItems} 134 137 profile={profile} 135 138 moderationOpts={moderationOpts} 139 + fromDialog={fromDialog} 136 140 /> 137 141 </Provider> 138 142 </Layout.Screen> ··· 144 148 currentListItems, 145 149 profile, 146 150 moderationOpts, 151 + fromDialog, 147 152 }: { 148 153 currentStarterPack?: AppBskyGraphDefs.StarterPackView 149 154 currentListItems?: AppBskyGraphDefs.ListItemView[] 150 155 profile: AppBskyActorDefs.ProfileViewDetailed 151 156 moderationOpts: ModerationOpts 157 + fromDialog?: boolean 152 158 }) { 153 159 const navigation = useNavigation<NavigationProp>() 154 160 const {_} = useLingui() 155 161 const setMinimalShellMode = useSetMinimalShellMode() 156 162 const [state, dispatch] = useWizardState() 157 163 const {currentAccount} = useSession() 164 + 158 165 const {data: currentProfile} = useProfileQuery({ 159 166 did: currentAccount?.did, 160 167 staleTime: 0, ··· 213 220 }) 214 221 Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)]) 215 222 dispatch({type: 'SetProcessing', processing: false}) 216 - navigation.replace('StarterPack', { 217 - name: currentAccount!.handle, 218 - rkey, 219 - new: true, 220 - }) 221 - } 222 223 223 - const onSuccessEdit = () => { 224 - if (navigation.canGoBack()) { 224 + // If launched from ProfileMenu dialog, notify the dialog and go back 225 + if (fromDialog) { 225 226 navigation.goBack() 227 + notifyDialogSuccess() 226 228 } else { 229 + // Original behavior for other entry points 227 230 navigation.replace('StarterPack', { 228 231 name: currentAccount!.handle, 229 - rkey: parsed!.rkey, 232 + rkey, 233 + new: true, 230 234 }) 235 + } 236 + } 237 + 238 + const onSuccessEdit = () => { 239 + // If launched from ProfileMenu dialog, go back to stay on profile page 240 + if (fromDialog) { 241 + navigation.goBack() 242 + } else { 243 + // Original behavior for other entry points 244 + if (navigation.canGoBack()) { 245 + navigation.goBack() 246 + } else { 247 + navigation.replace('StarterPack', { 248 + name: currentAccount!.handle, 249 + rkey: parsed!.rkey, 250 + }) 251 + } 231 252 } 232 253 } 233 254
+43 -4
src/state/queries/actor-starter-packs.ts
··· 1 - import {AppBskyGraphGetActorStarterPacks} from '@atproto/api' 2 1 import { 3 - InfiniteData, 4 - QueryClient, 5 - QueryKey, 2 + type AppBskyGraphGetActorStarterPacks, 3 + type AppBskyGraphGetStarterPacksWithMembership, 4 + } from '@atproto/api' 5 + import { 6 + type InfiniteData, 7 + type QueryClient, 8 + type QueryKey, 6 9 useInfiniteQuery, 7 10 } from '@tanstack/react-query' 8 11 9 12 import {useAgent} from '#/state/session' 10 13 11 14 export const RQKEY_ROOT = 'actor-starter-packs' 15 + export const RQKEY_WITH_MEMBERSHIP_ROOT = 'actor-starter-packs-with-membership' 12 16 export const RQKEY = (did?: string) => [RQKEY_ROOT, did] 17 + export const RQKEY_WITH_MEMBERSHIP = (did?: string) => [ 18 + RQKEY_WITH_MEMBERSHIP_ROOT, 19 + did, 20 + ] 13 21 14 22 export function useActorStarterPacksQuery({ 15 23 did, ··· 30 38 queryKey: RQKEY(did), 31 39 queryFn: async ({pageParam}: {pageParam?: string}) => { 32 40 const res = await agent.app.bsky.graph.getActorStarterPacks({ 41 + actor: did!, 42 + limit: 10, 43 + cursor: pageParam, 44 + }) 45 + return res.data 46 + }, 47 + enabled: Boolean(did) && enabled, 48 + initialPageParam: undefined, 49 + getNextPageParam: lastPage => lastPage.cursor, 50 + }) 51 + } 52 + 53 + export function useActorStarterPacksWithMembershipsQuery({ 54 + did, 55 + enabled = true, 56 + }: { 57 + did?: string 58 + enabled?: boolean 59 + }) { 60 + const agent = useAgent() 61 + 62 + return useInfiniteQuery< 63 + AppBskyGraphGetStarterPacksWithMembership.OutputSchema, 64 + Error, 65 + InfiniteData<AppBskyGraphGetStarterPacksWithMembership.OutputSchema>, 66 + QueryKey, 67 + string | undefined 68 + >({ 69 + queryKey: RQKEY_WITH_MEMBERSHIP(did), 70 + queryFn: async ({pageParam}: {pageParam?: string}) => { 71 + const res = await agent.app.bsky.graph.getStarterPacksWithMembership({ 33 72 actor: did!, 34 73 limit: 10, 35 74 cursor: pageParam,
+20
src/view/com/profile/ProfileMenu.tsx
··· 27 27 import * as Toast from '#/view/com/util/Toast' 28 28 import {Button, ButtonIcon} from '#/components/Button' 29 29 import {useDialogControl} from '#/components/Dialog' 30 + import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog' 30 31 import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' 31 32 import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 32 33 import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck' ··· 45 46 } from '#/components/icons/Person' 46 47 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 47 48 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 49 + import {StarterPack} from '#/components/icons/StarterPack' 48 50 import {EditLiveDialog} from '#/components/live/EditLiveDialog' 49 51 import {GoLiveDialog} from '#/components/live/GoLiveDialog' 50 52 import * as Menu from '#/components/Menu' ··· 88 90 const blockPromptControl = Prompt.usePromptControl() 89 91 const loggedOutWarningPromptControl = Prompt.usePromptControl() 90 92 const goLiveDialogControl = useDialogControl() 93 + const addToStarterPacksDialogControl = useDialogControl() 91 94 92 95 const showLoggedOutWarning = React.useMemo(() => { 93 96 return ( ··· 301 304 </> 302 305 )} 303 306 <Menu.Item 307 + testID="profileHeaderDropdownStarterPackAddRemoveBtn" 308 + label={_(msg`Add to starter packs`)} 309 + onPress={addToStarterPacksDialogControl.open}> 310 + <Menu.ItemText> 311 + <Trans>Add to starter packs</Trans> 312 + </Menu.ItemText> 313 + <Menu.ItemIcon icon={StarterPack} /> 314 + </Menu.Item> 315 + <Menu.Item 304 316 testID="profileHeaderDropdownListAddRemoveBtn" 305 317 label={_(msg`Add to lists`)} 306 318 onPress={onPressAddRemoveLists}> ··· 439 451 ) : null} 440 452 </Menu.Outer> 441 453 </Menu.Root> 454 + 455 + {currentAccount && ( 456 + <StarterPackDialog 457 + control={addToStarterPacksDialogControl} 458 + accountDid={currentAccount.did} 459 + targetDid={profile.did} 460 + /> 461 + )} 442 462 443 463 <ReportDialog 444 464 control={reportDialogControl}