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

Starter pack dialog implementations
This commit is contained in:
jim
2025-08-25 23:23:20 -07:00
committed by GitHub
10 changed files with 574 additions and 77 deletions
@@ -180,7 +180,7 @@ function CreateAnother() {
color="secondary" color="secondary"
size="small" size="small"
style={[a.self_center]} style={[a.self_center]}
onPress={() => navigation.navigate('StarterPackWizard')}> onPress={() => navigation.navigate('StarterPackWizard', {})}>
<ButtonText> <ButtonText>
<Trans>Create another</Trans> <Trans>Create another</Trans>
</ButtonText> </ButtonText>
@@ -238,7 +238,7 @@ function Empty() {
], ],
}) })
const navToWizard = useCallback(() => { const navToWizard = useCallback(() => {
navigation.navigate('StarterPackWizard') navigation.navigate('StarterPackWizard', {})
}, [navigation]) }, [navigation])
const wrappedNavToWizard = requireEmailVerification(navToWizard, { const wrappedNavToWizard = requireEmailVerification(navToWizard, {
instructions: [ instructions: [
@@ -322,7 +322,7 @@ function Empty() {
color="secondary" color="secondary"
cta={_(msg`Let me choose`)} cta={_(msg`Let me choose`)}
onPress={() => { onPress={() => {
navigation.navigate('StarterPackWizard') navigation.navigate('StarterPackWizard', {})
}} }}
/> />
</Prompt.Actions> </Prompt.Actions>
@@ -11,7 +11,6 @@ import {useLingui} from '@lingui/react'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {useSession} from '#/state/session'
import {type ListMethods} from '#/view/com/util/List' import {type ListMethods} from '#/view/com/util/List'
import { import {
type WizardAction, type WizardAction,
@@ -48,7 +47,6 @@ export function WizardEditListDialog({
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {currentAccount} = useSession()
const initialNumToRender = useInitialNumToRender() const initialNumToRender = useInitialNumToRender()
const listRef = useRef<ListMethods>(null) const listRef = useRef<ListMethods>(null)
@@ -56,10 +54,7 @@ export function WizardEditListDialog({
const getData = () => { const getData = () => {
if (state.currentStep === 'Feeds') return state.feeds if (state.currentStep === 'Feeds') return state.feeds
return [ return [profile, ...state.profiles.filter(p => p.did !== profile.did)]
profile,
...state.profiles.filter(p => p.did !== currentAccount?.did),
]
} }
const renderItem = ({item}: ListRenderItemInfo<any>) => const renderItem = ({item}: ListRenderItemInfo<any>) =>
@@ -131,10 +131,13 @@ export function WizardProfileCard({
}) { }) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const isMe = profile.did === currentAccount?.did // Determine the "main" profile for this starter pack - either targetDid or current account
const included = isMe || state.profiles.some(p => p.did === profile.did) const targetProfileDid = state.targetDid || currentAccount?.did
const isTarget = profile.did === targetProfileDid
const included = isTarget || state.profiles.some(p => p.did === profile.did)
const disabled = const disabled =
isMe || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1) isTarget ||
(!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1)
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar') const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
const displayName = profile.displayName const displayName = profile.displayName
? sanitizeDisplayName(profile.displayName) ? sanitizeDisplayName(profile.displayName)
@@ -144,7 +147,7 @@ export function WizardProfileCard({
if (disabled) return if (disabled) return
Keyboard.dismiss() Keyboard.dismiss()
if (profile.did === currentAccount?.did) return if (profile.did === targetProfileDid) return
if (!included) { if (!included) {
dispatch({type: 'AddProfile', profile}) dispatch({type: 'AddProfile', profile})
@@ -0,0 +1,399 @@
import React from 'react'
import {View} from 'react-native'
import {
type AppBskyGraphGetStarterPacksWithMembership,
AppBskyGraphStarterpack,
} from '@atproto/api'
import {msg, Plural, 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 {isWeb} from '#/platform/detection'
import {
invalidateActorStarterPacksWithMembershipQuery,
useActorStarterPacksWithMembershipsQuery,
} from '#/state/queries/actor-starter-packs'
import {
useListMembershipAddMutation,
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships'
import * as Toast from '#/view/com/util/Toast'
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 {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import {AvatarStack} from '../AvatarStack'
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
export type StarterPackDialogProps = {
control: Dialog.DialogControlProps
targetDid: string
enabled?: boolean
}
export function StarterPackDialog({
control,
targetDid,
enabled,
}: StarterPackDialogProps) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const requireEmailVerification = useRequireEmailVerification()
const navToWizard = React.useCallback(() => {
control.close()
navigation.navigate('StarterPackWizard', {
fromDialog: true,
targetDid: targetDid,
onSuccess: () => {
setTimeout(() => {
if (!control.isOpen) {
control.open()
}
}, 0)
},
})
}, [navigation, control, targetDid])
const wrappedNavToWizard = requireEmailVerification(navToWizard, {
instructions: [
<Trans key="nav">
Before creating a starter pack, you must first verify your email.
</Trans>,
],
})
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<StarterPackList
control={control}
onStartWizard={wrappedNavToWizard}
targetDid={targetDid}
enabled={enabled}
/>
</Dialog.Outer>
)
}
function Empty({onStartWizard}: {onStartWizard: () => void}) {
const {_} = useLingui()
const t = useTheme()
isWeb
return (
<View style={[a.gap_2xl, {paddingTop: isWeb ? 100 : 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>
<View style={[a.align_center]}>
<Button
label={_(msg`Create starter pack`)}
color="secondary_inverted"
size="small"
onPress={onStartWizard}>
<ButtonText>
<Trans comment="Text on button to create a new starter pack">
Create
</Trans>
</ButtonText>
<ButtonIcon icon={PlusLarge_Stroke2_Corner0_Rounded} />
</Button>
</View>
</View>
)
}
function StarterPackList({
control,
onStartWizard,
targetDid,
enabled,
}: {
control: Dialog.DialogControlProps
onStartWizard: () => void
targetDid: string
enabled?: boolean
}) {
const {_} = useLingui()
const t = useTheme()
const {
data,
refetch,
isError,
isLoading,
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 onClose = React.useCallback(() => {
control.close()
}, [control])
const XIcon = React.useMemo(() => {
return (
<TimesLarge_Stroke2_Corner0_Rounded
fill={t.atoms.text_contrast_medium.color}
/>
)
}, [t])
const listHeader = (
<>
<View
style={[
{justifyContent: 'space-between', flexDirection: 'row'},
isWeb ? a.mb_2xl : a.my_lg,
a.align_center,
]}>
<Text style={[a.text_lg, a.font_bold]}>
<Trans>Add to starter packs</Trans>
</Text>
<Button label={_(msg`Close`)} onPress={onClose}>
<ButtonIcon icon={() => XIcon} />
</Button>
</View>
{membershipItems.length > 0 && (
<>
<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 comment="Text on button to create a new starter pack">
Create
</Trans>
</ButtonText>
<ButtonIcon icon={PlusLarge_Stroke2_Corner0_Rounded} />
</Button>
</View>
<Divider />
</>
)}
</>
)
return (
<Dialog.InnerFlatList
data={isLoading ? [{}] : membershipItems}
renderItem={
isLoading
? () => (
<View style={[a.align_center, a.py_2xl]}>
<Loader size="xl" />
</View>
)
: renderItem
}
keyExtractor={
isLoading
? () => 'starter_pack_dialog_loader'
: (item: StarterPackWithMembership) => item.starterPack.uri
}
refreshing={false}
onRefresh={_onRefresh}
onEndReached={_onEndReached}
onEndReachedThreshold={0.1}
ListHeaderComponent={listHeader}
ListEmptyComponent={<Empty onStartWizard={onStartWizard} />}
style={isWeb ? [a.px_md, {minHeight: 500}] : [a.px_2xl, a.pt_lg]}
/>
)
}
function StarterPackItem({
starterPackWithMembership,
targetDid,
}: {
starterPackWithMembership: StarterPackWithMembership
targetDid: string
}) {
const {_} = useLingui()
const t = useTheme()
const queryClient = useQueryClient()
const starterPack = starterPackWithMembership.starterPack
const isInPack = !!starterPackWithMembership.listItem
const [isPendingRefresh, setIsPendingRefresh] = React.useState(false)
const {mutate: addMembership} = useListMembershipAddMutation({
onSuccess: () => {
Toast.show(_(msg`Added to starter pack`))
// Use a timeout to wait for the appview to update, matching the pattern
// in list-memberships.ts
setTimeout(() => {
invalidateActorStarterPacksWithMembershipQuery({
queryClient,
did: targetDid,
})
setIsPendingRefresh(false)
}, 1e3)
},
onError: () => {
Toast.show(_(msg`Failed to add to starter pack`), 'xmark')
setIsPendingRefresh(false)
},
})
const {mutate: removeMembership} = useListMembershipRemoveMutation({
onSuccess: () => {
Toast.show(_(msg`Removed from starter pack`))
// Use a timeout to wait for the appview to update, matching the pattern
// in list-memberships.ts
setTimeout(() => {
invalidateActorStarterPacksWithMembershipQuery({
queryClient,
did: targetDid,
})
setIsPendingRefresh(false)
}, 1e3)
},
onError: () => {
Toast.show(_(msg`Failed to remove from starter pack`), 'xmark')
setIsPendingRefresh(false)
},
})
const handleToggleMembership = () => {
if (!starterPack.list?.uri || isPendingRefresh) return
const listUri = starterPack.list.uri
setIsPendingRefresh(true)
if (!isInPack) {
addMembership({
listUri: listUri,
actorDid: targetDid,
})
} else {
if (!starterPackWithMembership.listItem?.uri) {
console.error('Cannot remove: missing membership URI')
setIsPendingRefresh(false)
return
}
removeMembership({
listUri: listUri,
actorDid: targetDid,
membershipUri: starterPackWithMembership.listItem.uri,
})
}
}
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 && (
<>
<AvatarStack
size={32}
profiles={starterPack.listItemsSample
?.slice(0, 4)
.map(p => p.subject)}
/>
{starterPack.list?.listItemCount &&
starterPack.list.listItemCount > 4 && (
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
a.ml_xs,
]}>
<Trans>
<Plural
value={starterPack.list.listItemCount - 4}
other="+# more"
/>
</Trans>
</Text>
)}
</>
)}
</View>
</View>
<Button
label={isInPack ? _(msg`Remove`) : _(msg`Add`)}
color={isInPack ? 'secondary' : 'primary'}
size="tiny"
disabled={isPendingRefresh}
onPress={handleToggleMembership}>
<ButtonText>
{isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
</ButtonText>
</Button>
</View>
)
}
+8 -15
View File
@@ -1,10 +1,10 @@
import { import {
$Typed, type $Typed,
AppBskyActorDefs, type AppBskyActorDefs,
AppBskyGraphGetStarterPack, type AppBskyGraphGetStarterPack,
BskyAgent, type BskyAgent,
ComAtprotoRepoApplyWrites, type ComAtprotoRepoApplyWrites,
Facet, type Facet,
} from '@atproto/api' } from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -15,7 +15,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {enforceLen} from '#/lib/strings/helpers' import {enforceLen} from '#/lib/strings/helpers'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
export const createStarterPackList = async ({ export const createStarterPackList = async ({
name, name,
@@ -46,14 +46,7 @@ export const createStarterPackList = async ({
if (!list) throw new Error('List creation failed') if (!list) throw new Error('List creation failed')
await agent.com.atproto.repo.applyWrites({ await agent.com.atproto.repo.applyWrites({
repo: agent.session!.did, repo: agent.session!.did,
writes: [ writes: profiles.map(p => createListItem({did: p.did, listUri: list.uri})),
createListItem({did: agent.session!.did, listUri: list.uri}),
].concat(
profiles
// Ensure we don't have ourselves in this list twice
.filter(p => p.did !== agent.session!.did)
.map(p => createListItem({did: p.did, listUri: list.uri})),
),
}) })
return list return list
+5 -1
View File
@@ -79,7 +79,11 @@ export type CommonNavigatorParams = {
Start: {name: string; rkey: string} Start: {name: string; rkey: string}
StarterPack: {name: string; rkey: string; new?: boolean} StarterPack: {name: string; rkey: string; new?: boolean}
StarterPackShort: {code: string} StarterPackShort: {code: string}
StarterPackWizard: undefined StarterPackWizard: {
fromDialog?: boolean
targetDid?: string
onSuccess?: () => void
}
StarterPackEdit: {rkey?: string} StarterPackEdit: {rkey?: string}
VideoFeed: VideoFeedSourceContext VideoFeed: VideoFeedSourceContext
} }
+9 -8
View File
@@ -7,7 +7,6 @@ import {
import {msg, plural} from '@lingui/macro' import {msg, plural} from '@lingui/macro'
import {STARTER_PACK_MAX_SIZE} from '#/lib/constants' import {STARTER_PACK_MAX_SIZE} from '#/lib/constants'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
@@ -37,6 +36,7 @@ interface State {
processing: boolean processing: boolean
error?: string error?: string
transitionDirection: 'Backward' | 'Forward' transitionDirection: 'Backward' | 'Forward'
targetDid?: string
} }
type TStateContext = [State, (action: Action) => void] type TStateContext = [State, (action: Action) => void]
@@ -118,15 +118,17 @@ function reducer(state: State, action: Action): State {
export function Provider({ export function Provider({
starterPack, starterPack,
listItems, listItems,
targetProfile,
children, children,
}: { }: {
starterPack?: AppBskyGraphDefs.StarterPackView starterPack?: AppBskyGraphDefs.StarterPackView
listItems?: AppBskyGraphDefs.ListItemView[] listItems?: AppBskyGraphDefs.ListItemView[]
targetProfile: bsky.profile.AnyProfileView
children: React.ReactNode children: React.ReactNode
}) { }) {
const {currentAccount} = useSession()
const createInitialState = (): State => { const createInitialState = (): State => {
const targetDid = targetProfile?.did
if ( if (
starterPack && starterPack &&
bsky.validate(starterPack.record, AppBskyGraphStarterpack.validateRecord) bsky.validate(starterPack.record, AppBskyGraphStarterpack.validateRecord)
@@ -136,23 +138,22 @@ export function Provider({
currentStep: 'Details', currentStep: 'Details',
name: starterPack.record.name, name: starterPack.record.name,
description: starterPack.record.description, description: starterPack.record.description,
profiles: profiles: listItems?.map(i => i.subject) ?? [],
listItems
?.map(i => i.subject)
.filter(p => p.did !== currentAccount?.did) ?? [],
feeds: starterPack.feeds ?? [], feeds: starterPack.feeds ?? [],
processing: false, processing: false,
transitionDirection: 'Forward', transitionDirection: 'Forward',
targetDid,
} }
} }
return { return {
canNext: true, canNext: true,
currentStep: 'Details', currentStep: 'Details',
profiles: [], profiles: [targetProfile],
feeds: [], feeds: [],
processing: false, processing: false,
transitionDirection: 'Forward', transitionDirection: 'Forward',
targetDid,
} }
} }
+58 -22
View File
@@ -68,12 +68,19 @@ export function Wizard({
CommonNavigatorParams, CommonNavigatorParams,
'StarterPackEdit' | 'StarterPackWizard' '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 targetDid = 'targetDid' in params ? params.targetDid : undefined
const onSuccess = 'onSuccess' in params ? params.onSuccess : undefined
const {currentAccount} = useSession() const {currentAccount} = useSession()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const {_} = useLingui() const {_} = useLingui()
// Use targetDid if provided (from dialog), otherwise use current account
const profileDid = targetDid || currentAccount!.did
const { const {
data: starterPack, data: starterPack,
isLoading: isLoadingStarterPack, isLoading: isLoadingStarterPack,
@@ -91,7 +98,7 @@ export function Wizard({
data: profile, data: profile,
isLoading: isLoadingProfile, isLoading: isLoadingProfile,
isError: isErrorProfile, isError: isErrorProfile,
} = useProfileQuery({did: currentAccount?.did}) } = useProfileQuery({did: profileDid})
const isEdit = Boolean(rkey) const isEdit = Boolean(rkey)
const isReady = const isReady =
@@ -127,12 +134,17 @@ export function Wizard({
<Layout.Screen <Layout.Screen
testID="starterPackWizardScreen" testID="starterPackWizardScreen"
style={web([{minHeight: 0}, a.flex_1])}> style={web([{minHeight: 0}, a.flex_1])}>
<Provider starterPack={starterPack} listItems={listItems}> <Provider
starterPack={starterPack}
listItems={listItems}
targetProfile={profile}>
<WizardInner <WizardInner
currentStarterPack={starterPack} currentStarterPack={starterPack}
currentListItems={listItems} currentListItems={listItems}
profile={profile} profile={profile}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
fromDialog={fromDialog}
onSuccess={onSuccess}
/> />
</Provider> </Provider>
</Layout.Screen> </Layout.Screen>
@@ -144,17 +156,22 @@ function WizardInner({
currentListItems, currentListItems,
profile, profile,
moderationOpts, moderationOpts,
fromDialog,
onSuccess,
}: { }: {
currentStarterPack?: AppBskyGraphDefs.StarterPackView currentStarterPack?: AppBskyGraphDefs.StarterPackView
currentListItems?: AppBskyGraphDefs.ListItemView[] currentListItems?: AppBskyGraphDefs.ListItemView[]
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
fromDialog?: boolean
onSuccess?: () => void
}) { }) {
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {_} = useLingui() const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const [state, dispatch] = useWizardState() const [state, dispatch] = useWizardState()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({ const {data: currentProfile} = useProfileQuery({
did: currentAccount?.did, did: currentAccount?.did,
staleTime: 0, staleTime: 0,
@@ -213,12 +230,18 @@ function WizardInner({
}) })
Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)]) Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)])
dispatch({type: 'SetProcessing', processing: false}) dispatch({type: 'SetProcessing', processing: false})
if (fromDialog) {
navigation.goBack()
onSuccess?.()
} else {
navigation.replace('StarterPack', { navigation.replace('StarterPack', {
name: currentAccount!.handle, name: profile!.handle,
rkey, rkey,
new: true, new: true,
}) })
} }
}
const onSuccessEdit = () => { const onSuccessEdit = () => {
if (navigation.canGoBack()) { if (navigation.canGoBack()) {
@@ -285,10 +308,7 @@ function WizardInner({
) )
} }
const items = const items = state.currentStep === 'Profiles' ? state.profiles : state.feeds
state.currentStep === 'Profiles'
? [profile, ...state.profiles]
: state.feeds
const isEditEnabled = const isEditEnabled =
(state.currentStep === 'Profiles' && items.length > 1) || (state.currentStep === 'Profiles' && items.length > 1) ||
@@ -340,11 +360,7 @@ function WizardInner({
</Container> </Container>
{state.currentStep !== 'Details' && ( {state.currentStep !== 'Details' && (
<Footer <Footer onNext={onNext} nextBtnText={currUiStrings.nextBtn} />
onNext={onNext}
nextBtnText={currUiStrings.nextBtn}
profile={profile}
/>
)} )}
<WizardEditListDialog <WizardEditListDialog
control={editDialogControl} control={editDialogControl}
@@ -392,20 +408,15 @@ function Container({children}: {children: React.ReactNode}) {
function Footer({ function Footer({
onNext, onNext,
nextBtnText, nextBtnText,
profile,
}: { }: {
onNext: () => void onNext: () => void
nextBtnText: string nextBtnText: string
profile: AppBskyActorDefs.ProfileViewDetailed
}) { }) {
const t = useTheme() const t = useTheme()
const [state] = useWizardState() const [state] = useWizardState()
const {bottom: bottomInset} = useSafeAreaInsets() const {bottom: bottomInset} = useSafeAreaInsets()
const {currentAccount} = useSession()
const items = const items = state.currentStep === 'Profiles' ? state.profiles : state.feeds
state.currentStep === 'Profiles'
? [profile, ...state.profiles]
: state.feeds
const minimumItems = state.currentStep === 'Profiles' ? 8 : 0 const minimumItems = state.currentStep === 'Profiles' ? 8 : 0
@@ -471,11 +482,23 @@ function Footer({
<Text style={[a.text_center, textStyles]}> <Text style={[a.text_center, textStyles]}>
{ {
items.length < 2 ? ( items.length < 2 ? (
currentAccount?.did === items[0].did ? (
<Trans> <Trans>
It's just you right now! Add more people to your starter pack It's just you right now! Add more people to your starter
by searching above. pack by searching above.
</Trans> </Trans>
) : (
<Trans>
It's just{' '}
<Text style={[a.font_bold, textStyles]} emoji>
{getName(items[0])}{' '}
</Text>
right now! Add more people to your starter pack by searching
above.
</Trans>
)
) : items.length === 2 ? ( ) : items.length === 2 ? (
currentAccount?.did === items[0].did ? (
<Trans> <Trans>
<Text style={[a.font_bold, textStyles]}>You</Text> and <Text style={[a.font_bold, textStyles]}>You</Text> and
<Text> </Text> <Text> </Text>
@@ -484,6 +507,19 @@ function Footer({
</Text> </Text>
are included in your starter pack are included in your starter pack
</Trans> </Trans>
) : (
<Trans>
<Text style={[a.font_bold, textStyles]}>
{getName(items[0])}
</Text>{' '}
and
<Text> </Text>
<Text style={[a.font_bold, textStyles]} emoji>
{getName(items[1] /* [0] is self, skip it */)}{' '}
</Text>
are included in your starter pack
</Trans>
)
) : items.length > 2 ? ( ) : items.length > 2 ? (
<Trans context="profiles"> <Trans context="profiles">
<Text style={[a.font_bold, textStyles]} emoji> <Text style={[a.font_bold, textStyles]} emoji>
+53 -4
View File
@@ -1,15 +1,23 @@
import {AppBskyGraphGetActorStarterPacks} from '@atproto/api'
import { import {
InfiniteData, type AppBskyGraphGetActorStarterPacks,
QueryClient, type AppBskyGraphGetStarterPacksWithMembership,
QueryKey, } from '@atproto/api'
import {
type InfiniteData,
type QueryClient,
type QueryKey,
useInfiniteQuery, useInfiniteQuery,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
export const RQKEY_ROOT = 'actor-starter-packs' 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 = (did?: string) => [RQKEY_ROOT, did]
export const RQKEY_WITH_MEMBERSHIP = (did?: string) => [
RQKEY_WITH_MEMBERSHIP_ROOT,
did,
]
export function useActorStarterPacksQuery({ export function useActorStarterPacksQuery({
did, 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({ export async function invalidateActorStarterPacksQuery({
queryClient, queryClient,
did, did,
@@ -51,3 +90,13 @@ export async function invalidateActorStarterPacksQuery({
}) { }) {
await queryClient.invalidateQueries({queryKey: RQKEY(did)}) await queryClient.invalidateQueries({queryKey: RQKEY(did)})
} }
export async function invalidateActorStarterPacksWithMembershipQuery({
queryClient,
did,
}: {
queryClient: QueryClient
did: string
}) {
await queryClient.invalidateQueries({queryKey: RQKEY_WITH_MEMBERSHIP(did)})
}
+17
View File
@@ -27,6 +27,7 @@ import {EventStopper} from '#/view/com/util/EventStopper'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog'
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck' import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
@@ -45,6 +46,7 @@ import {
} from '#/components/icons/Person' } from '#/components/icons/Person'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {StarterPack} from '#/components/icons/StarterPack'
import {EditLiveDialog} from '#/components/live/EditLiveDialog' import {EditLiveDialog} from '#/components/live/EditLiveDialog'
import {GoLiveDialog} from '#/components/live/GoLiveDialog' import {GoLiveDialog} from '#/components/live/GoLiveDialog'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
@@ -88,6 +90,7 @@ let ProfileMenu = ({
const blockPromptControl = Prompt.usePromptControl() const blockPromptControl = Prompt.usePromptControl()
const loggedOutWarningPromptControl = Prompt.usePromptControl() const loggedOutWarningPromptControl = Prompt.usePromptControl()
const goLiveDialogControl = useDialogControl() const goLiveDialogControl = useDialogControl()
const addToStarterPacksDialogControl = useDialogControl()
const showLoggedOutWarning = React.useMemo(() => { const showLoggedOutWarning = React.useMemo(() => {
return ( return (
@@ -300,6 +303,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 <Menu.Item
testID="profileHeaderDropdownListAddRemoveBtn" testID="profileHeaderDropdownListAddRemoveBtn"
label={_(msg`Add to lists`)} label={_(msg`Add to lists`)}
@@ -440,6 +452,11 @@ let ProfileMenu = ({
</Menu.Outer> </Menu.Outer>
</Menu.Root> </Menu.Root>
<StarterPackDialog
control={addToStarterPacksDialogControl}
targetDid={profile.did}
/>
<ReportDialog <ReportDialog
control={reportDialogControl} control={reportDialogControl}
subject={{ subject={{