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

parameterize the initial profile for starter pack profile select wizard screen

+115 -111
+1 -5
src/components/StarterPack/Wizard/WizardEditListDialog.tsx
··· 48 }) { 49 const {_} = useLingui() 50 const t = useTheme() 51 - const {currentAccount} = useSession() 52 const initialNumToRender = useInitialNumToRender() 53 54 const listRef = useRef<ListMethods>(null) ··· 56 const getData = () => { 57 if (state.currentStep === 'Feeds') return state.feeds 58 59 - return [ 60 - profile, 61 - ...state.profiles.filter(p => p.did !== currentAccount?.did), 62 - ] 63 } 64 65 const renderItem = ({item}: ListRenderItemInfo<any>) =>
··· 48 }) { 49 const {_} = useLingui() 50 const t = useTheme() 51 const initialNumToRender = useInitialNumToRender() 52 53 const listRef = useRef<ListMethods>(null) ··· 55 const getData = () => { 56 if (state.currentStep === 'Feeds') return state.feeds 57 58 + return [profile, ...state.profiles.filter(p => p.did !== profile.did)] 59 } 60 61 const renderItem = ({item}: ListRenderItemInfo<any>) =>
+7 -4
src/components/StarterPack/Wizard/WizardListCard.tsx
··· 131 }) { 132 const {currentAccount} = useSession() 133 134 - const isMe = profile.did === currentAccount?.did 135 - const included = isMe || state.profiles.some(p => p.did === profile.did) 136 const disabled = 137 - isMe || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1) 138 const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar') 139 const displayName = profile.displayName 140 ? sanitizeDisplayName(profile.displayName) ··· 144 if (disabled) return 145 146 Keyboard.dismiss() 147 - if (profile.did === currentAccount?.did) return 148 149 if (!included) { 150 dispatch({type: 'AddProfile', profile})
··· 131 }) { 132 const {currentAccount} = useSession() 133 134 + // Determine the "main" profile for this starter pack - either targetDid or current account 135 + const targetProfileDid = state.targetDid || currentAccount?.did 136 + const isTarget = profile.did === targetProfileDid 137 + const included = isTarget || state.profiles.some(p => p.did === profile.did) 138 const disabled = 139 + isTarget || 140 + (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1) 141 const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar') 142 const displayName = profile.displayName 143 ? sanitizeDisplayName(profile.displayName) ··· 147 if (disabled) return 148 149 Keyboard.dismiss() 150 + if (profile.did === targetProfileDid) return 151 152 if (!included) { 153 dispatch({type: 'AddProfile', profile})
+50 -60
src/components/dialogs/StarterPackDialog.tsx
··· 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 { ··· 35 type StarterPackWithMembership = 36 AppBskyGraphGetStarterPacksWithMembership.StarterPackWithMembership 37 38 - // Simple module-level state for dialog coordination 39 let dialogCallbacks: { 40 onSuccess?: () => void 41 } = {} ··· 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) { ··· 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: [ ··· 85 }) 86 87 const onClose = React.useCallback(() => { 88 - // setCurrentView('initial') 89 control.close() 90 }, [control]) 91 ··· 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 ··· 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>}
··· 12 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 13 import {type NavigationProp} from '#/lib/routes/types' 14 import { 15 + invalidateActorStarterPacksWithMembershipQuery, 16 useActorStarterPacksWithMembershipsQuery, 17 } from '#/state/queries/actor-starter-packs' 18 import { ··· 35 type StarterPackWithMembership = 36 AppBskyGraphGetStarterPacksWithMembership.StarterPackWithMembership 37 38 let dialogCallbacks: { 39 onSuccess?: () => void 40 } = {} ··· 47 48 export type StarterPackDialogProps = { 49 control: Dialog.DialogControlProps 50 targetDid: string 51 enabled?: boolean 52 } 53 54 export function StarterPackDialog({ 55 control, 56 targetDid, 57 enabled, 58 }: StarterPackDialogProps) { ··· 70 71 const navToWizard = React.useCallback(() => { 72 control.close() 73 + navigation.navigate('StarterPackWizard', { 74 + fromDialog: true, 75 + targetDid: targetDid, 76 + }) 77 + }, [navigation, control, targetDid]) 78 79 const wrappedNavToWizard = requireEmailVerification(navToWizard, { 80 instructions: [ ··· 85 }) 86 87 const onClose = React.useCallback(() => { 88 control.close() 89 }, [control]) 90 ··· 251 const {_} = useLingui() 252 const t = useTheme() 253 const queryClient = useQueryClient() 254 255 const starterPack = starterPackWithMembership.starterPack 256 const isInPack = !!starterPackWithMembership.listItem 257 258 + const {mutate: addMembership, isPending: isAddingPending} = 259 + useListMembershipAddMutation({ 260 + onSuccess: () => { 261 + Toast.show(_(msg`Added to starter pack`)) 262 + invalidateActorStarterPacksWithMembershipQuery({ 263 + queryClient, 264 + did: targetDid, 265 + }) 266 + }, 267 + onError: () => { 268 + Toast.show(_(msg`Failed to add to starter pack`), 'xmark') 269 + }, 270 + }) 271 272 + const {mutate: removeMembership, isPending: isRemovingPending} = 273 + useListMembershipRemoveMutation({ 274 + onSuccess: () => { 275 + Toast.show(_(msg`Removed from starter pack`)) 276 + invalidateActorStarterPacksWithMembershipQuery({ 277 + queryClient, 278 + did: targetDid, 279 + }) 280 + }, 281 + onError: () => { 282 + Toast.show(_(msg`Failed to remove from starter pack`), 'xmark') 283 + }, 284 + }) 285 286 + const isMutating = isAddingPending || isRemovingPending 287 288 + const handleToggleMembership = () => { 289 + if (!starterPack.list?.uri || isMutating) return 290 291 const listUri = starterPack.list.uri 292 293 + if (!isInPack) { 294 + addMembership({ 295 + listUri: listUri, 296 + actorDid: targetDid, 297 + }) 298 + } else { 299 + if (!starterPackWithMembership.listItem?.uri) { 300 + console.error('Cannot remove: missing membership URI') 301 + return 302 } 303 + removeMembership({ 304 + listUri: listUri, 305 + actorDid: targetDid, 306 + membershipUri: starterPackWithMembership.listItem.uri, 307 + }) 308 } 309 } 310 ··· 367 label={isInPack ? _(msg`Remove`) : _(msg`Add`)} 368 color={isInPack ? 'secondary' : 'primary'} 369 size="tiny" 370 + disabled={isMutating} 371 onPress={handleToggleMembership}> 372 <ButtonText> 373 {isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
+8 -15
src/lib/generate-starterpack.ts
··· 1 import { 2 - $Typed, 3 - AppBskyActorDefs, 4 - AppBskyGraphGetStarterPack, 5 - BskyAgent, 6 - ComAtprotoRepoApplyWrites, 7 - Facet, 8 } from '@atproto/api' 9 import {msg} from '@lingui/macro' 10 import {useLingui} from '@lingui/react' ··· 15 import {sanitizeHandle} from '#/lib/strings/handles' 16 import {enforceLen} from '#/lib/strings/helpers' 17 import {useAgent} from '#/state/session' 18 - import * as bsky from '#/types/bsky' 19 20 export const createStarterPackList = async ({ 21 name, ··· 46 if (!list) throw new Error('List creation failed') 47 await agent.com.atproto.repo.applyWrites({ 48 repo: agent.session!.did, 49 - writes: [ 50 - createListItem({did: agent.session!.did, listUri: list.uri}), 51 - ].concat( 52 - profiles 53 - // Ensure we don't have ourselves in this list twice 54 - .filter(p => p.did !== agent.session!.did) 55 - .map(p => createListItem({did: p.did, listUri: list.uri})), 56 - ), 57 }) 58 59 return list
··· 1 import { 2 + type $Typed, 3 + type AppBskyActorDefs, 4 + type AppBskyGraphGetStarterPack, 5 + type BskyAgent, 6 + type ComAtprotoRepoApplyWrites, 7 + type Facet, 8 } from '@atproto/api' 9 import {msg} from '@lingui/macro' 10 import {useLingui} from '@lingui/react' ··· 15 import {sanitizeHandle} from '#/lib/strings/handles' 16 import {enforceLen} from '#/lib/strings/helpers' 17 import {useAgent} from '#/state/session' 18 + import type * as bsky from '#/types/bsky' 19 20 export const createStarterPackList = async ({ 21 name, ··· 46 if (!list) throw new Error('List creation failed') 47 await agent.com.atproto.repo.applyWrites({ 48 repo: agent.session!.did, 49 + writes: profiles.map(p => createListItem({did: p.did, listUri: list.uri})), 50 }) 51 52 return list
+1 -1
src/lib/routes/types.ts
··· 79 Start: {name: string; rkey: string} 80 StarterPack: {name: string; rkey: string; new?: boolean} 81 StarterPackShort: {code: string} 82 - StarterPackWizard: {fromDialog?: boolean} 83 StarterPackEdit: {rkey?: string} 84 VideoFeed: VideoFeedSourceContext 85 }
··· 79 Start: {name: string; rkey: string} 80 StarterPack: {name: string; rkey: string; new?: boolean} 81 StarterPackShort: {code: string} 82 + StarterPackWizard: {fromDialog?: boolean; targetDid?: string} 83 StarterPackEdit: {rkey?: string} 84 VideoFeed: VideoFeedSourceContext 85 }
+9 -8
src/screens/StarterPack/Wizard/State.tsx
··· 7 import {msg, plural} from '@lingui/macro' 8 9 import {STARTER_PACK_MAX_SIZE} from '#/lib/constants' 10 - import {useSession} from '#/state/session' 11 import * as Toast from '#/view/com/util/Toast' 12 import * as bsky from '#/types/bsky' 13 ··· 37 processing: boolean 38 error?: string 39 transitionDirection: 'Backward' | 'Forward' 40 } 41 42 type TStateContext = [State, (action: Action) => void] ··· 118 export function Provider({ 119 starterPack, 120 listItems, 121 children, 122 }: { 123 starterPack?: AppBskyGraphDefs.StarterPackView 124 listItems?: AppBskyGraphDefs.ListItemView[] 125 children: React.ReactNode 126 }) { 127 - const {currentAccount} = useSession() 128 - 129 const createInitialState = (): State => { 130 if ( 131 starterPack && 132 bsky.validate(starterPack.record, AppBskyGraphStarterpack.validateRecord) ··· 136 currentStep: 'Details', 137 name: starterPack.record.name, 138 description: starterPack.record.description, 139 - profiles: 140 - listItems 141 - ?.map(i => i.subject) 142 - .filter(p => p.did !== currentAccount?.did) ?? [], 143 feeds: starterPack.feeds ?? [], 144 processing: false, 145 transitionDirection: 'Forward', 146 } 147 } 148 149 return { 150 canNext: true, 151 currentStep: 'Details', 152 - profiles: [], 153 feeds: [], 154 processing: false, 155 transitionDirection: 'Forward', 156 } 157 } 158
··· 7 import {msg, plural} from '@lingui/macro' 8 9 import {STARTER_PACK_MAX_SIZE} from '#/lib/constants' 10 import * as Toast from '#/view/com/util/Toast' 11 import * as bsky from '#/types/bsky' 12 ··· 36 processing: boolean 37 error?: string 38 transitionDirection: 'Backward' | 'Forward' 39 + targetDid?: string 40 } 41 42 type TStateContext = [State, (action: Action) => void] ··· 118 export function Provider({ 119 starterPack, 120 listItems, 121 + targetProfile, 122 children, 123 }: { 124 starterPack?: AppBskyGraphDefs.StarterPackView 125 listItems?: AppBskyGraphDefs.ListItemView[] 126 + targetProfile: bsky.profile.AnyProfileView 127 children: React.ReactNode 128 }) { 129 const createInitialState = (): State => { 130 + const targetDid = targetProfile?.did 131 + 132 if ( 133 starterPack && 134 bsky.validate(starterPack.record, AppBskyGraphStarterpack.validateRecord) ··· 138 currentStep: 'Details', 139 name: starterPack.record.name, 140 description: starterPack.record.description, 141 + profiles: listItems?.map(i => i.subject) ?? [], 142 feeds: starterPack.feeds ?? [], 143 processing: false, 144 transitionDirection: 'Forward', 145 + targetDid, 146 } 147 } 148 149 return { 150 canNext: true, 151 currentStep: 'Details', 152 + profiles: [targetProfile], 153 feeds: [], 154 processing: false, 155 transitionDirection: 'Forward', 156 + targetDid, 157 } 158 } 159
+29 -18
src/screens/StarterPack/Wizard/index.tsx
··· 72 const params = route.params ?? {} 73 const rkey = 'rkey' in params ? params.rkey : undefined 74 const fromDialog = 'fromDialog' in params ? params.fromDialog : false 75 const {currentAccount} = useSession() 76 const moderationOpts = useModerationOpts() 77 78 const {_} = useLingui() 79 80 const { 81 data: starterPack, ··· 94 data: profile, 95 isLoading: isLoadingProfile, 96 isError: isErrorProfile, 97 - } = useProfileQuery({did: currentAccount?.did}) 98 99 const isEdit = Boolean(rkey) 100 const isReady = ··· 130 <Layout.Screen 131 testID="starterPackWizardScreen" 132 style={web([{minHeight: 0}, a.flex_1])}> 133 - <Provider starterPack={starterPack} listItems={listItems}> 134 <WizardInner 135 currentStarterPack={starterPack} 136 currentListItems={listItems} ··· 228 } else { 229 // Original behavior for other entry points 230 navigation.replace('StarterPack', { 231 - name: currentAccount!.handle, 232 rkey, 233 new: true, 234 }) ··· 245 navigation.goBack() 246 } else { 247 navigation.replace('StarterPack', { 248 - name: currentAccount!.handle, 249 rkey: parsed!.rkey, 250 }) 251 } ··· 281 currentListItems: currentListItems, 282 }) 283 } else { 284 createStarterPack({ 285 name: state.name?.trim() || getDefaultName(), 286 description: state.description?.trim(), ··· 306 ) 307 } 308 309 - const items = 310 - state.currentStep === 'Profiles' 311 - ? [profile, ...state.profiles] 312 - : state.feeds 313 314 const isEditEnabled = 315 (state.currentStep === 'Profiles' && items.length > 1) || ··· 413 function Footer({ 414 onNext, 415 nextBtnText, 416 - profile, 417 }: { 418 onNext: () => void 419 nextBtnText: string 420 - profile: AppBskyActorDefs.ProfileViewDetailed 421 }) { 422 const t = useTheme() 423 const [state] = useWizardState() 424 const {bottom: bottomInset} = useSafeAreaInsets() 425 - 426 - const items = 427 - state.currentStep === 'Profiles' 428 - ? [profile, ...state.profiles] 429 - : state.feeds 430 431 const minimumItems = state.currentStep === 'Profiles' ? 8 : 0 432 ··· 493 { 494 items.length < 2 ? ( 495 <Trans> 496 - It's just you right now! Add more people to your starter pack 497 - by searching above. 498 </Trans> 499 ) : items.length === 2 ? ( 500 <Trans> 501 - <Text style={[a.font_bold, textStyles]}>You</Text> and 502 <Text> </Text> 503 <Text style={[a.font_bold, textStyles]} emoji> 504 {getName(items[1] /* [0] is self, skip it */)}{' '}
··· 72 const params = route.params ?? {} 73 const rkey = 'rkey' in params ? params.rkey : undefined 74 const fromDialog = 'fromDialog' in params ? params.fromDialog : false 75 + const targetDid = 'targetDid' in params ? params.targetDid : undefined 76 const {currentAccount} = useSession() 77 const moderationOpts = useModerationOpts() 78 79 const {_} = useLingui() 80 + 81 + // Use targetDid if provided (from dialog), otherwise use current account 82 + const profileDid = targetDid || currentAccount!.did 83 84 const { 85 data: starterPack, ··· 98 data: profile, 99 isLoading: isLoadingProfile, 100 isError: isErrorProfile, 101 + } = useProfileQuery({did: profileDid}) 102 103 const isEdit = Boolean(rkey) 104 const isReady = ··· 134 <Layout.Screen 135 testID="starterPackWizardScreen" 136 style={web([{minHeight: 0}, a.flex_1])}> 137 + <Provider 138 + starterPack={starterPack} 139 + listItems={listItems} 140 + targetProfile={profile}> 141 <WizardInner 142 currentStarterPack={starterPack} 143 currentListItems={listItems} ··· 235 } else { 236 // Original behavior for other entry points 237 navigation.replace('StarterPack', { 238 + name: profile!.handle, 239 rkey, 240 new: true, 241 }) ··· 252 navigation.goBack() 253 } else { 254 navigation.replace('StarterPack', { 255 + name: profile!.handle, 256 rkey: parsed!.rkey, 257 }) 258 } ··· 288 currentListItems: currentListItems, 289 }) 290 } else { 291 + console.log('Creating new starter pack: ', state.profiles) 292 createStarterPack({ 293 name: state.name?.trim() || getDefaultName(), 294 description: state.description?.trim(), ··· 314 ) 315 } 316 317 + const items = state.currentStep === 'Profiles' ? state.profiles : state.feeds 318 319 const isEditEnabled = 320 (state.currentStep === 'Profiles' && items.length > 1) || ··· 418 function Footer({ 419 onNext, 420 nextBtnText, 421 }: { 422 onNext: () => void 423 nextBtnText: string 424 }) { 425 const t = useTheme() 426 const [state] = useWizardState() 427 const {bottom: bottomInset} = useSafeAreaInsets() 428 + const {currentAccount} = useSession() 429 + const items = state.currentStep === 'Profiles' ? state.profiles : state.feeds 430 431 const minimumItems = state.currentStep === 'Profiles' ? 8 : 0 432 ··· 493 { 494 items.length < 2 ? ( 495 <Trans> 496 + It's just{' '} 497 + <Text style={[a.font_bold, textStyles]} emoji> 498 + {currentAccount?.did === items[0].did 499 + ? 'you' 500 + : getName(items[0])}{' '} 501 + </Text> 502 + right now! Add more people to your starter pack by searching 503 + above. 504 </Trans> 505 ) : items.length === 2 ? ( 506 <Trans> 507 + <Text style={[a.font_bold, textStyles]}> 508 + {currentAccount?.did === items[0].did 509 + ? 'you' 510 + : getName(items[0])} 511 + </Text>{' '} 512 + and 513 <Text> </Text> 514 <Text style={[a.font_bold, textStyles]} emoji> 515 {getName(items[1] /* [0] is self, skip it */)}{' '}
+10
src/state/queries/actor-starter-packs.ts
··· 90 }) { 91 await queryClient.invalidateQueries({queryKey: RQKEY(did)}) 92 }
··· 90 }) { 91 await queryClient.invalidateQueries({queryKey: RQKEY(did)}) 92 } 93 + 94 + export async function invalidateActorStarterPacksWithMembershipQuery({ 95 + queryClient, 96 + did, 97 + }: { 98 + queryClient: QueryClient 99 + did: string 100 + }) { 101 + await queryClient.invalidateQueries({queryKey: RQKEY_WITH_MEMBERSHIP(did)}) 102 + }