Bluesky app fork with some witchin' additions 💫

Merge pull request #8806 from internet-development/binaryfiddler/starter-pack-part2

Starter pack dialog implementations

authored by

jim and committed by
GitHub
bc8e3ba3 e4c5fb7d

+574 -77
+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>
+1 -6
src/components/StarterPack/Wizard/WizardEditListDialog.tsx
··· 11 11 12 12 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 13 13 import {isWeb} from '#/platform/detection' 14 - import {useSession} from '#/state/session' 15 14 import {type ListMethods} from '#/view/com/util/List' 16 15 import { 17 16 type WizardAction, ··· 48 47 }) { 49 48 const {_} = useLingui() 50 49 const t = useTheme() 51 - const {currentAccount} = useSession() 52 50 const initialNumToRender = useInitialNumToRender() 53 51 54 52 const listRef = useRef<ListMethods>(null) ··· 56 54 const getData = () => { 57 55 if (state.currentStep === 'Feeds') return state.feeds 58 56 59 - return [ 60 - profile, 61 - ...state.profiles.filter(p => p.did !== currentAccount?.did), 62 - ] 57 + return [profile, ...state.profiles.filter(p => p.did !== profile.did)] 63 58 } 64 59 65 60 const renderItem = ({item}: ListRenderItemInfo<any>) =>
+7 -4
src/components/StarterPack/Wizard/WizardListCard.tsx
··· 131 131 }) { 132 132 const {currentAccount} = useSession() 133 133 134 - const isMe = profile.did === currentAccount?.did 135 - const included = isMe || state.profiles.some(p => p.did === profile.did) 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) 136 138 const disabled = 137 - isMe || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1) 139 + isTarget || 140 + (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1) 138 141 const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar') 139 142 const displayName = profile.displayName 140 143 ? sanitizeDisplayName(profile.displayName) ··· 144 147 if (disabled) return 145 148 146 149 Keyboard.dismiss() 147 - if (profile.did === currentAccount?.did) return 150 + if (profile.did === targetProfileDid) return 148 151 149 152 if (!included) { 150 153 dispatch({type: 'AddProfile', profile})
+399
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, Plural, 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 {isWeb} from '#/platform/detection' 15 + import { 16 + invalidateActorStarterPacksWithMembershipQuery, 17 + useActorStarterPacksWithMembershipsQuery, 18 + } from '#/state/queries/actor-starter-packs' 19 + import { 20 + useListMembershipAddMutation, 21 + useListMembershipRemoveMutation, 22 + } from '#/state/queries/list-memberships' 23 + import * as Toast from '#/view/com/util/Toast' 24 + import {atoms as a, useTheme} from '#/alf' 25 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 26 + import * as Dialog from '#/components/Dialog' 27 + import {Divider} from '#/components/Divider' 28 + import {Loader} from '#/components/Loader' 29 + import {Text} from '#/components/Typography' 30 + import * as bsky from '#/types/bsky' 31 + import {AvatarStack} from '../AvatarStack' 32 + import {PlusLarge_Stroke2_Corner0_Rounded} from '../icons/Plus' 33 + import {StarterPack} from '../icons/StarterPack' 34 + import {TimesLarge_Stroke2_Corner0_Rounded} from '../icons/Times' 35 + 36 + type StarterPackWithMembership = 37 + AppBskyGraphGetStarterPacksWithMembership.StarterPackWithMembership 38 + 39 + export type StarterPackDialogProps = { 40 + control: Dialog.DialogControlProps 41 + targetDid: string 42 + enabled?: boolean 43 + } 44 + 45 + export function StarterPackDialog({ 46 + control, 47 + targetDid, 48 + enabled, 49 + }: StarterPackDialogProps) { 50 + const {_} = useLingui() 51 + const navigation = useNavigation<NavigationProp>() 52 + const requireEmailVerification = useRequireEmailVerification() 53 + 54 + const navToWizard = React.useCallback(() => { 55 + control.close() 56 + navigation.navigate('StarterPackWizard', { 57 + fromDialog: true, 58 + targetDid: targetDid, 59 + onSuccess: () => { 60 + setTimeout(() => { 61 + if (!control.isOpen) { 62 + control.open() 63 + } 64 + }, 0) 65 + }, 66 + }) 67 + }, [navigation, control, targetDid]) 68 + 69 + const wrappedNavToWizard = requireEmailVerification(navToWizard, { 70 + instructions: [ 71 + <Trans key="nav"> 72 + Before creating a starter pack, you must first verify your email. 73 + </Trans>, 74 + ], 75 + }) 76 + 77 + return ( 78 + <Dialog.Outer control={control}> 79 + <Dialog.Handle /> 80 + <StarterPackList 81 + control={control} 82 + onStartWizard={wrappedNavToWizard} 83 + targetDid={targetDid} 84 + enabled={enabled} 85 + /> 86 + </Dialog.Outer> 87 + ) 88 + } 89 + 90 + function Empty({onStartWizard}: {onStartWizard: () => void}) { 91 + const {_} = useLingui() 92 + const t = useTheme() 93 + 94 + isWeb 95 + return ( 96 + <View style={[a.gap_2xl, {paddingTop: isWeb ? 100 : 64}]}> 97 + <View style={[a.gap_xs, a.align_center]}> 98 + <StarterPack 99 + width={48} 100 + fill={t.atoms.border_contrast_medium.borderColor} 101 + /> 102 + <Text style={[a.text_center]}> 103 + <Trans>You have no starter packs.</Trans> 104 + </Text> 105 + </View> 106 + 107 + <View style={[a.align_center]}> 108 + <Button 109 + label={_(msg`Create starter pack`)} 110 + color="secondary_inverted" 111 + size="small" 112 + onPress={onStartWizard}> 113 + <ButtonText> 114 + <Trans comment="Text on button to create a new starter pack"> 115 + Create 116 + </Trans> 117 + </ButtonText> 118 + <ButtonIcon icon={PlusLarge_Stroke2_Corner0_Rounded} /> 119 + </Button> 120 + </View> 121 + </View> 122 + ) 123 + } 124 + 125 + function StarterPackList({ 126 + control, 127 + onStartWizard, 128 + targetDid, 129 + enabled, 130 + }: { 131 + control: Dialog.DialogControlProps 132 + onStartWizard: () => void 133 + targetDid: string 134 + enabled?: boolean 135 + }) { 136 + const {_} = useLingui() 137 + const t = useTheme() 138 + 139 + const { 140 + data, 141 + refetch, 142 + isError, 143 + isLoading, 144 + hasNextPage, 145 + isFetchingNextPage, 146 + fetchNextPage, 147 + } = useActorStarterPacksWithMembershipsQuery({did: targetDid, enabled}) 148 + 149 + const membershipItems = 150 + data?.pages.flatMap(page => page.starterPacksWithMembership) || [] 151 + 152 + const _onRefresh = React.useCallback(async () => { 153 + try { 154 + await refetch() 155 + } catch (err) { 156 + // Error handling is optional since this is just a refresh 157 + } 158 + }, [refetch]) 159 + 160 + const _onEndReached = React.useCallback(async () => { 161 + if (isFetchingNextPage || !hasNextPage || isError) return 162 + try { 163 + await fetchNextPage() 164 + } catch (err) { 165 + // Error handling is optional since this is just pagination 166 + } 167 + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 168 + 169 + const renderItem = React.useCallback( 170 + ({item}: {item: StarterPackWithMembership}) => ( 171 + <StarterPackItem starterPackWithMembership={item} targetDid={targetDid} /> 172 + ), 173 + [targetDid], 174 + ) 175 + 176 + const onClose = React.useCallback(() => { 177 + control.close() 178 + }, [control]) 179 + 180 + const XIcon = React.useMemo(() => { 181 + return ( 182 + <TimesLarge_Stroke2_Corner0_Rounded 183 + fill={t.atoms.text_contrast_medium.color} 184 + /> 185 + ) 186 + }, [t]) 187 + 188 + const listHeader = ( 189 + <> 190 + <View 191 + style={[ 192 + {justifyContent: 'space-between', flexDirection: 'row'}, 193 + isWeb ? a.mb_2xl : a.my_lg, 194 + a.align_center, 195 + ]}> 196 + <Text style={[a.text_lg, a.font_bold]}> 197 + <Trans>Add to starter packs</Trans> 198 + </Text> 199 + <Button label={_(msg`Close`)} onPress={onClose}> 200 + <ButtonIcon icon={() => XIcon} /> 201 + </Button> 202 + </View> 203 + {membershipItems.length > 0 && ( 204 + <> 205 + <View 206 + style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}> 207 + <Text style={[a.text_md, a.font_bold]}> 208 + <Trans>New starter pack</Trans> 209 + </Text> 210 + <Button 211 + label={_(msg`Create starter pack`)} 212 + color="secondary_inverted" 213 + size="small" 214 + onPress={onStartWizard}> 215 + <ButtonText> 216 + <Trans comment="Text on button to create a new starter pack"> 217 + Create 218 + </Trans> 219 + </ButtonText> 220 + <ButtonIcon icon={PlusLarge_Stroke2_Corner0_Rounded} /> 221 + </Button> 222 + </View> 223 + <Divider /> 224 + </> 225 + )} 226 + </> 227 + ) 228 + 229 + return ( 230 + <Dialog.InnerFlatList 231 + data={isLoading ? [{}] : membershipItems} 232 + renderItem={ 233 + isLoading 234 + ? () => ( 235 + <View style={[a.align_center, a.py_2xl]}> 236 + <Loader size="xl" /> 237 + </View> 238 + ) 239 + : renderItem 240 + } 241 + keyExtractor={ 242 + isLoading 243 + ? () => 'starter_pack_dialog_loader' 244 + : (item: StarterPackWithMembership) => item.starterPack.uri 245 + } 246 + refreshing={false} 247 + onRefresh={_onRefresh} 248 + onEndReached={_onEndReached} 249 + onEndReachedThreshold={0.1} 250 + ListHeaderComponent={listHeader} 251 + ListEmptyComponent={<Empty onStartWizard={onStartWizard} />} 252 + style={isWeb ? [a.px_md, {minHeight: 500}] : [a.px_2xl, a.pt_lg]} 253 + /> 254 + ) 255 + } 256 + 257 + function StarterPackItem({ 258 + starterPackWithMembership, 259 + targetDid, 260 + }: { 261 + starterPackWithMembership: StarterPackWithMembership 262 + targetDid: string 263 + }) { 264 + const {_} = useLingui() 265 + const t = useTheme() 266 + const queryClient = useQueryClient() 267 + 268 + const starterPack = starterPackWithMembership.starterPack 269 + const isInPack = !!starterPackWithMembership.listItem 270 + 271 + const [isPendingRefresh, setIsPendingRefresh] = React.useState(false) 272 + 273 + const {mutate: addMembership} = useListMembershipAddMutation({ 274 + onSuccess: () => { 275 + Toast.show(_(msg`Added to starter pack`)) 276 + // Use a timeout to wait for the appview to update, matching the pattern 277 + // in list-memberships.ts 278 + setTimeout(() => { 279 + invalidateActorStarterPacksWithMembershipQuery({ 280 + queryClient, 281 + did: targetDid, 282 + }) 283 + setIsPendingRefresh(false) 284 + }, 1e3) 285 + }, 286 + onError: () => { 287 + Toast.show(_(msg`Failed to add to starter pack`), 'xmark') 288 + setIsPendingRefresh(false) 289 + }, 290 + }) 291 + 292 + const {mutate: removeMembership} = useListMembershipRemoveMutation({ 293 + onSuccess: () => { 294 + Toast.show(_(msg`Removed from starter pack`)) 295 + // Use a timeout to wait for the appview to update, matching the pattern 296 + // in list-memberships.ts 297 + setTimeout(() => { 298 + invalidateActorStarterPacksWithMembershipQuery({ 299 + queryClient, 300 + did: targetDid, 301 + }) 302 + setIsPendingRefresh(false) 303 + }, 1e3) 304 + }, 305 + onError: () => { 306 + Toast.show(_(msg`Failed to remove from starter pack`), 'xmark') 307 + setIsPendingRefresh(false) 308 + }, 309 + }) 310 + 311 + const handleToggleMembership = () => { 312 + if (!starterPack.list?.uri || isPendingRefresh) return 313 + 314 + const listUri = starterPack.list.uri 315 + 316 + setIsPendingRefresh(true) 317 + 318 + if (!isInPack) { 319 + addMembership({ 320 + listUri: listUri, 321 + actorDid: targetDid, 322 + }) 323 + } else { 324 + if (!starterPackWithMembership.listItem?.uri) { 325 + console.error('Cannot remove: missing membership URI') 326 + setIsPendingRefresh(false) 327 + return 328 + } 329 + removeMembership({ 330 + listUri: listUri, 331 + actorDid: targetDid, 332 + membershipUri: starterPackWithMembership.listItem.uri, 333 + }) 334 + } 335 + } 336 + 337 + const {record} = starterPack 338 + 339 + if ( 340 + !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>( 341 + record, 342 + AppBskyGraphStarterpack.isRecord, 343 + ) 344 + ) { 345 + return null 346 + } 347 + 348 + return ( 349 + <View style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}> 350 + <View> 351 + <Text emoji style={[a.text_md, a.font_bold]} numberOfLines={1}> 352 + {record.name} 353 + </Text> 354 + 355 + <View style={[a.flex_row, a.align_center, a.mt_xs]}> 356 + {starterPack.listItemsSample && 357 + starterPack.listItemsSample.length > 0 && ( 358 + <> 359 + <AvatarStack 360 + size={32} 361 + profiles={starterPack.listItemsSample 362 + ?.slice(0, 4) 363 + .map(p => p.subject)} 364 + /> 365 + 366 + {starterPack.list?.listItemCount && 367 + starterPack.list.listItemCount > 4 && ( 368 + <Text 369 + style={[ 370 + a.text_sm, 371 + t.atoms.text_contrast_medium, 372 + a.ml_xs, 373 + ]}> 374 + <Trans> 375 + <Plural 376 + value={starterPack.list.listItemCount - 4} 377 + other="+# more" 378 + /> 379 + </Trans> 380 + </Text> 381 + )} 382 + </> 383 + )} 384 + </View> 385 + </View> 386 + 387 + <Button 388 + label={isInPack ? _(msg`Remove`) : _(msg`Add`)} 389 + color={isInPack ? 'secondary' : 'primary'} 390 + size="tiny" 391 + disabled={isPendingRefresh} 392 + onPress={handleToggleMembership}> 393 + <ButtonText> 394 + {isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>} 395 + </ButtonText> 396 + </Button> 397 + </View> 398 + ) 399 + }
+8 -15
src/lib/generate-starterpack.ts
··· 1 1 import { 2 - $Typed, 3 - AppBskyActorDefs, 4 - AppBskyGraphGetStarterPack, 5 - BskyAgent, 6 - ComAtprotoRepoApplyWrites, 7 - Facet, 2 + type $Typed, 3 + type AppBskyActorDefs, 4 + type AppBskyGraphGetStarterPack, 5 + type BskyAgent, 6 + type ComAtprotoRepoApplyWrites, 7 + type Facet, 8 8 } from '@atproto/api' 9 9 import {msg} from '@lingui/macro' 10 10 import {useLingui} from '@lingui/react' ··· 15 15 import {sanitizeHandle} from '#/lib/strings/handles' 16 16 import {enforceLen} from '#/lib/strings/helpers' 17 17 import {useAgent} from '#/state/session' 18 - import * as bsky from '#/types/bsky' 18 + import type * as bsky from '#/types/bsky' 19 19 20 20 export const createStarterPackList = async ({ 21 21 name, ··· 46 46 if (!list) throw new Error('List creation failed') 47 47 await agent.com.atproto.repo.applyWrites({ 48 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 - ), 49 + writes: profiles.map(p => createListItem({did: p.did, listUri: list.uri})), 57 50 }) 58 51 59 52 return list
+5 -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: { 83 + fromDialog?: boolean 84 + targetDid?: string 85 + onSuccess?: () => void 86 + } 83 87 StarterPackEdit: {rkey?: string} 84 88 VideoFeed: VideoFeedSourceContext 85 89 }
+9 -8
src/screens/StarterPack/Wizard/State.tsx
··· 7 7 import {msg, plural} from '@lingui/macro' 8 8 9 9 import {STARTER_PACK_MAX_SIZE} from '#/lib/constants' 10 - import {useSession} from '#/state/session' 11 10 import * as Toast from '#/view/com/util/Toast' 12 11 import * as bsky from '#/types/bsky' 13 12 ··· 37 36 processing: boolean 38 37 error?: string 39 38 transitionDirection: 'Backward' | 'Forward' 39 + targetDid?: string 40 40 } 41 41 42 42 type TStateContext = [State, (action: Action) => void] ··· 118 118 export function Provider({ 119 119 starterPack, 120 120 listItems, 121 + targetProfile, 121 122 children, 122 123 }: { 123 124 starterPack?: AppBskyGraphDefs.StarterPackView 124 125 listItems?: AppBskyGraphDefs.ListItemView[] 126 + targetProfile: bsky.profile.AnyProfileView 125 127 children: React.ReactNode 126 128 }) { 127 - const {currentAccount} = useSession() 128 - 129 129 const createInitialState = (): State => { 130 + const targetDid = targetProfile?.did 131 + 130 132 if ( 131 133 starterPack && 132 134 bsky.validate(starterPack.record, AppBskyGraphStarterpack.validateRecord) ··· 136 138 currentStep: 'Details', 137 139 name: starterPack.record.name, 138 140 description: starterPack.record.description, 139 - profiles: 140 - listItems 141 - ?.map(i => i.subject) 142 - .filter(p => p.did !== currentAccount?.did) ?? [], 141 + profiles: listItems?.map(i => i.subject) ?? [], 143 142 feeds: starterPack.feeds ?? [], 144 143 processing: false, 145 144 transitionDirection: 'Forward', 145 + targetDid, 146 146 } 147 147 } 148 148 149 149 return { 150 150 canNext: true, 151 151 currentStep: 'Details', 152 - profiles: [], 152 + profiles: [targetProfile], 153 153 feeds: [], 154 154 processing: false, 155 155 transitionDirection: 'Forward', 156 + targetDid, 156 157 } 157 158 } 158 159
+72 -36
src/screens/StarterPack/Wizard/index.tsx
··· 68 68 CommonNavigatorParams, 69 69 'StarterPackEdit' | 'StarterPackWizard' 70 70 >) { 71 - const {rkey} = route.params ?? {} 71 + const params = route.params ?? {} 72 + const rkey = 'rkey' in params ? params.rkey : undefined 73 + const fromDialog = 'fromDialog' in params ? params.fromDialog : false 74 + const targetDid = 'targetDid' in params ? params.targetDid : undefined 75 + const onSuccess = 'onSuccess' in params ? params.onSuccess : undefined 72 76 const {currentAccount} = useSession() 73 77 const moderationOpts = useModerationOpts() 74 78 75 79 const {_} = useLingui() 80 + 81 + // Use targetDid if provided (from dialog), otherwise use current account 82 + const profileDid = targetDid || currentAccount!.did 76 83 77 84 const { 78 85 data: starterPack, ··· 91 98 data: profile, 92 99 isLoading: isLoadingProfile, 93 100 isError: isErrorProfile, 94 - } = useProfileQuery({did: currentAccount?.did}) 101 + } = useProfileQuery({did: profileDid}) 95 102 96 103 const isEdit = Boolean(rkey) 97 104 const isReady = ··· 127 134 <Layout.Screen 128 135 testID="starterPackWizardScreen" 129 136 style={web([{minHeight: 0}, a.flex_1])}> 130 - <Provider starterPack={starterPack} listItems={listItems}> 137 + <Provider 138 + starterPack={starterPack} 139 + listItems={listItems} 140 + targetProfile={profile}> 131 141 <WizardInner 132 142 currentStarterPack={starterPack} 133 143 currentListItems={listItems} 134 144 profile={profile} 135 145 moderationOpts={moderationOpts} 146 + fromDialog={fromDialog} 147 + onSuccess={onSuccess} 136 148 /> 137 149 </Provider> 138 150 </Layout.Screen> ··· 144 156 currentListItems, 145 157 profile, 146 158 moderationOpts, 159 + fromDialog, 160 + onSuccess, 147 161 }: { 148 162 currentStarterPack?: AppBskyGraphDefs.StarterPackView 149 163 currentListItems?: AppBskyGraphDefs.ListItemView[] 150 164 profile: AppBskyActorDefs.ProfileViewDetailed 151 165 moderationOpts: ModerationOpts 166 + fromDialog?: boolean 167 + onSuccess?: () => void 152 168 }) { 153 169 const navigation = useNavigation<NavigationProp>() 154 170 const {_} = useLingui() 155 171 const setMinimalShellMode = useSetMinimalShellMode() 156 172 const [state, dispatch] = useWizardState() 157 173 const {currentAccount} = useSession() 174 + 158 175 const {data: currentProfile} = useProfileQuery({ 159 176 did: currentAccount?.did, 160 177 staleTime: 0, ··· 213 230 }) 214 231 Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)]) 215 232 dispatch({type: 'SetProcessing', processing: false}) 216 - navigation.replace('StarterPack', { 217 - name: currentAccount!.handle, 218 - rkey, 219 - new: true, 220 - }) 233 + 234 + if (fromDialog) { 235 + navigation.goBack() 236 + onSuccess?.() 237 + } else { 238 + navigation.replace('StarterPack', { 239 + name: profile!.handle, 240 + rkey, 241 + new: true, 242 + }) 243 + } 221 244 } 222 245 223 246 const onSuccessEdit = () => { ··· 285 308 ) 286 309 } 287 310 288 - const items = 289 - state.currentStep === 'Profiles' 290 - ? [profile, ...state.profiles] 291 - : state.feeds 311 + const items = state.currentStep === 'Profiles' ? state.profiles : state.feeds 292 312 293 313 const isEditEnabled = 294 314 (state.currentStep === 'Profiles' && items.length > 1) || ··· 340 360 </Container> 341 361 342 362 {state.currentStep !== 'Details' && ( 343 - <Footer 344 - onNext={onNext} 345 - nextBtnText={currUiStrings.nextBtn} 346 - profile={profile} 347 - /> 363 + <Footer onNext={onNext} nextBtnText={currUiStrings.nextBtn} /> 348 364 )} 349 365 <WizardEditListDialog 350 366 control={editDialogControl} ··· 392 408 function Footer({ 393 409 onNext, 394 410 nextBtnText, 395 - profile, 396 411 }: { 397 412 onNext: () => void 398 413 nextBtnText: string 399 - profile: AppBskyActorDefs.ProfileViewDetailed 400 414 }) { 401 415 const t = useTheme() 402 416 const [state] = useWizardState() 403 417 const {bottom: bottomInset} = useSafeAreaInsets() 404 - 405 - const items = 406 - state.currentStep === 'Profiles' 407 - ? [profile, ...state.profiles] 408 - : state.feeds 418 + const {currentAccount} = useSession() 419 + const items = state.currentStep === 'Profiles' ? state.profiles : state.feeds 409 420 410 421 const minimumItems = state.currentStep === 'Profiles' ? 8 : 0 411 422 ··· 471 482 <Text style={[a.text_center, textStyles]}> 472 483 { 473 484 items.length < 2 ? ( 474 - <Trans> 475 - It's just you right now! Add more people to your starter pack 476 - by searching above. 477 - </Trans> 485 + currentAccount?.did === items[0].did ? ( 486 + <Trans> 487 + It's just you right now! Add more people to your starter 488 + pack by searching above. 489 + </Trans> 490 + ) : ( 491 + <Trans> 492 + It's just{' '} 493 + <Text style={[a.font_bold, textStyles]} emoji> 494 + {getName(items[0])}{' '} 495 + </Text> 496 + right now! Add more people to your starter pack by searching 497 + above. 498 + </Trans> 499 + ) 478 500 ) : items.length === 2 ? ( 479 - <Trans> 480 - <Text style={[a.font_bold, textStyles]}>You</Text> and 481 - <Text> </Text> 482 - <Text style={[a.font_bold, textStyles]} emoji> 483 - {getName(items[1] /* [0] is self, skip it */)}{' '} 484 - </Text> 485 - are included in your starter pack 486 - </Trans> 501 + currentAccount?.did === items[0].did ? ( 502 + <Trans> 503 + <Text style={[a.font_bold, textStyles]}>You</Text> and 504 + <Text> </Text> 505 + <Text style={[a.font_bold, textStyles]} emoji> 506 + {getName(items[1] /* [0] is self, skip it */)}{' '} 507 + </Text> 508 + are included in your starter pack 509 + </Trans> 510 + ) : ( 511 + <Trans> 512 + <Text style={[a.font_bold, textStyles]}> 513 + {getName(items[0])} 514 + </Text>{' '} 515 + and 516 + <Text> </Text> 517 + <Text style={[a.font_bold, textStyles]} emoji> 518 + {getName(items[1] /* [0] is self, skip it */)}{' '} 519 + </Text> 520 + are included in your starter pack 521 + </Trans> 522 + ) 487 523 ) : items.length > 2 ? ( 488 524 <Trans context="profiles"> 489 525 <Text style={[a.font_bold, textStyles]} emoji>
+53 -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, ··· 42 50 }) 43 51 } 44 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({ 72 + actor: did!, 73 + limit: 10, 74 + cursor: pageParam, 75 + }) 76 + return res.data 77 + }, 78 + enabled: Boolean(did) && enabled, 79 + initialPageParam: undefined, 80 + getNextPageParam: lastPage => lastPage.cursor, 81 + }) 82 + } 83 + 45 84 export async function invalidateActorStarterPacksQuery({ 46 85 queryClient, 47 86 did, ··· 51 90 }) { 52 91 await queryClient.invalidateQueries({queryKey: RQKEY(did)}) 53 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 + }
+17
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 + <StarterPackDialog 456 + control={addToStarterPacksDialogControl} 457 + targetDid={profile.did} 458 + /> 442 459 443 460 <ReportDialog 444 461 control={reportDialogControl}