diff options
-rw-r--r-- | src/components/StarterPack/ProfileStarterPacks.tsx | 6 | ||||
-rw-r--r-- | src/components/StarterPack/Wizard/WizardEditListDialog.tsx | 7 | ||||
-rw-r--r-- | src/components/StarterPack/Wizard/WizardListCard.tsx | 11 | ||||
-rw-r--r-- | src/components/dialogs/StarterPackDialog.tsx | 399 | ||||
-rw-r--r-- | src/lib/generate-starterpack.ts | 23 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 6 | ||||
-rw-r--r-- | src/screens/StarterPack/Wizard/State.tsx | 17 | ||||
-rw-r--r-- | src/screens/StarterPack/Wizard/index.tsx | 108 | ||||
-rw-r--r-- | src/state/queries/actor-starter-packs.ts | 57 | ||||
-rw-r--r-- | src/view/com/profile/ProfileMenu.tsx | 17 |
10 files changed, 574 insertions, 77 deletions
diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx index de19b0bce..73aee28f4 100644 --- a/src/components/StarterPack/ProfileStarterPacks.tsx +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -180,7 +180,7 @@ function CreateAnother() { color="secondary" size="small" style={[a.self_center]} - onPress={() => navigation.navigate('StarterPackWizard')}> + onPress={() => navigation.navigate('StarterPackWizard', {})}> <ButtonText> <Trans>Create another</Trans> </ButtonText> @@ -238,7 +238,7 @@ function Empty() { ], }) const navToWizard = useCallback(() => { - navigation.navigate('StarterPackWizard') + navigation.navigate('StarterPackWizard', {}) }, [navigation]) const wrappedNavToWizard = requireEmailVerification(navToWizard, { instructions: [ @@ -322,7 +322,7 @@ function Empty() { color="secondary" cta={_(msg`Let me choose`)} onPress={() => { - navigation.navigate('StarterPackWizard') + navigation.navigate('StarterPackWizard', {}) }} /> </Prompt.Actions> diff --git a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx index 731323f7f..7dfde900f 100644 --- a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx +++ b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx @@ -11,7 +11,6 @@ import {useLingui} from '@lingui/react' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {isWeb} from '#/platform/detection' -import {useSession} from '#/state/session' import {type ListMethods} from '#/view/com/util/List' import { type WizardAction, @@ -48,7 +47,6 @@ export function WizardEditListDialog({ }) { const {_} = useLingui() const t = useTheme() - const {currentAccount} = useSession() const initialNumToRender = useInitialNumToRender() const listRef = useRef<ListMethods>(null) @@ -56,10 +54,7 @@ export function WizardEditListDialog({ const getData = () => { if (state.currentStep === 'Feeds') return state.feeds - return [ - profile, - ...state.profiles.filter(p => p.did !== currentAccount?.did), - ] + return [profile, ...state.profiles.filter(p => p.did !== profile.did)] } const renderItem = ({item}: ListRenderItemInfo<any>) => diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx index fbaa185a9..09c265d78 100644 --- a/src/components/StarterPack/Wizard/WizardListCard.tsx +++ b/src/components/StarterPack/Wizard/WizardListCard.tsx @@ -131,10 +131,13 @@ export function WizardProfileCard({ }) { const {currentAccount} = useSession() - const isMe = profile.did === currentAccount?.did - const included = isMe || state.profiles.some(p => p.did === profile.did) + // Determine the "main" profile for this starter pack - either targetDid or current account + const targetProfileDid = state.targetDid || currentAccount?.did + const isTarget = profile.did === targetProfileDid + const included = isTarget || state.profiles.some(p => p.did === profile.did) 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 displayName = profile.displayName ? sanitizeDisplayName(profile.displayName) @@ -144,7 +147,7 @@ export function WizardProfileCard({ if (disabled) return Keyboard.dismiss() - if (profile.did === currentAccount?.did) return + if (profile.did === targetProfileDid) return if (!included) { dispatch({type: 'AddProfile', profile}) diff --git a/src/components/dialogs/StarterPackDialog.tsx b/src/components/dialogs/StarterPackDialog.tsx new file mode 100644 index 000000000..ec041d401 --- /dev/null +++ b/src/components/dialogs/StarterPackDialog.tsx @@ -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> + ) +} diff --git a/src/lib/generate-starterpack.ts b/src/lib/generate-starterpack.ts index 11e334329..76bef3fbe 100644 --- a/src/lib/generate-starterpack.ts +++ b/src/lib/generate-starterpack.ts @@ -1,10 +1,10 @@ import { - $Typed, - AppBskyActorDefs, - AppBskyGraphGetStarterPack, - BskyAgent, - ComAtprotoRepoApplyWrites, - Facet, + type $Typed, + type AppBskyActorDefs, + type AppBskyGraphGetStarterPack, + type BskyAgent, + type ComAtprotoRepoApplyWrites, + type Facet, } from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -15,7 +15,7 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {enforceLen} from '#/lib/strings/helpers' import {useAgent} from '#/state/session' -import * as bsky from '#/types/bsky' +import type * as bsky from '#/types/bsky' export const createStarterPackList = async ({ name, @@ -46,14 +46,7 @@ export const createStarterPackList = async ({ if (!list) throw new Error('List creation failed') await agent.com.atproto.repo.applyWrites({ repo: agent.session!.did, - writes: [ - 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})), - ), + writes: profiles.map(p => createListItem({did: p.did, listUri: list.uri})), }) return list diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index b1db5caa6..1725fdfb4 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -79,7 +79,11 @@ export type CommonNavigatorParams = { Start: {name: string; rkey: string} StarterPack: {name: string; rkey: string; new?: boolean} StarterPackShort: {code: string} - StarterPackWizard: undefined + StarterPackWizard: { + fromDialog?: boolean + targetDid?: string + onSuccess?: () => void + } StarterPackEdit: {rkey?: string} VideoFeed: VideoFeedSourceContext } diff --git a/src/screens/StarterPack/Wizard/State.tsx b/src/screens/StarterPack/Wizard/State.tsx index 7fae8ca6d..f34218219 100644 --- a/src/screens/StarterPack/Wizard/State.tsx +++ b/src/screens/StarterPack/Wizard/State.tsx @@ -7,7 +7,6 @@ import { import {msg, plural} from '@lingui/macro' import {STARTER_PACK_MAX_SIZE} from '#/lib/constants' -import {useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import * as bsky from '#/types/bsky' @@ -37,6 +36,7 @@ interface State { processing: boolean error?: string transitionDirection: 'Backward' | 'Forward' + targetDid?: string } type TStateContext = [State, (action: Action) => void] @@ -118,15 +118,17 @@ function reducer(state: State, action: Action): State { export function Provider({ starterPack, listItems, + targetProfile, children, }: { starterPack?: AppBskyGraphDefs.StarterPackView listItems?: AppBskyGraphDefs.ListItemView[] + targetProfile: bsky.profile.AnyProfileView children: React.ReactNode }) { - const {currentAccount} = useSession() - const createInitialState = (): State => { + const targetDid = targetProfile?.did + if ( starterPack && bsky.validate(starterPack.record, AppBskyGraphStarterpack.validateRecord) @@ -136,23 +138,22 @@ export function Provider({ currentStep: 'Details', name: starterPack.record.name, description: starterPack.record.description, - profiles: - listItems - ?.map(i => i.subject) - .filter(p => p.did !== currentAccount?.did) ?? [], + profiles: listItems?.map(i => i.subject) ?? [], feeds: starterPack.feeds ?? [], processing: false, transitionDirection: 'Forward', + targetDid, } } return { canNext: true, currentStep: 'Details', - profiles: [], + profiles: [targetProfile], feeds: [], processing: false, transitionDirection: 'Forward', + targetDid, } } diff --git a/src/screens/StarterPack/Wizard/index.tsx b/src/screens/StarterPack/Wizard/index.tsx index 8256349df..839faf9aa 100644 --- a/src/screens/StarterPack/Wizard/index.tsx +++ b/src/screens/StarterPack/Wizard/index.tsx @@ -68,12 +68,19 @@ export function Wizard({ CommonNavigatorParams, 'StarterPackEdit' | 'StarterPackWizard' >) { - const {rkey} = route.params ?? {} + const params = route.params ?? {} + const rkey = 'rkey' in params ? params.rkey : undefined + const fromDialog = 'fromDialog' in params ? params.fromDialog : false + const targetDid = 'targetDid' in params ? params.targetDid : undefined + const onSuccess = 'onSuccess' in params ? params.onSuccess : undefined const {currentAccount} = useSession() const moderationOpts = useModerationOpts() const {_} = useLingui() + // Use targetDid if provided (from dialog), otherwise use current account + const profileDid = targetDid || currentAccount!.did + const { data: starterPack, isLoading: isLoadingStarterPack, @@ -91,7 +98,7 @@ export function Wizard({ data: profile, isLoading: isLoadingProfile, isError: isErrorProfile, - } = useProfileQuery({did: currentAccount?.did}) + } = useProfileQuery({did: profileDid}) const isEdit = Boolean(rkey) const isReady = @@ -127,12 +134,17 @@ export function Wizard({ <Layout.Screen testID="starterPackWizardScreen" style={web([{minHeight: 0}, a.flex_1])}> - <Provider starterPack={starterPack} listItems={listItems}> + <Provider + starterPack={starterPack} + listItems={listItems} + targetProfile={profile}> <WizardInner currentStarterPack={starterPack} currentListItems={listItems} profile={profile} moderationOpts={moderationOpts} + fromDialog={fromDialog} + onSuccess={onSuccess} /> </Provider> </Layout.Screen> @@ -144,17 +156,22 @@ function WizardInner({ currentListItems, profile, moderationOpts, + fromDialog, + onSuccess, }: { currentStarterPack?: AppBskyGraphDefs.StarterPackView currentListItems?: AppBskyGraphDefs.ListItemView[] profile: AppBskyActorDefs.ProfileViewDetailed moderationOpts: ModerationOpts + fromDialog?: boolean + onSuccess?: () => void }) { const navigation = useNavigation<NavigationProp>() const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() const [state, dispatch] = useWizardState() const {currentAccount} = useSession() + const {data: currentProfile} = useProfileQuery({ did: currentAccount?.did, staleTime: 0, @@ -213,11 +230,17 @@ function WizardInner({ }) Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)]) dispatch({type: 'SetProcessing', processing: false}) - navigation.replace('StarterPack', { - name: currentAccount!.handle, - rkey, - new: true, - }) + + if (fromDialog) { + navigation.goBack() + onSuccess?.() + } else { + navigation.replace('StarterPack', { + name: profile!.handle, + rkey, + new: true, + }) + } } const onSuccessEdit = () => { @@ -285,10 +308,7 @@ function WizardInner({ ) } - const items = - state.currentStep === 'Profiles' - ? [profile, ...state.profiles] - : state.feeds + const items = state.currentStep === 'Profiles' ? state.profiles : state.feeds const isEditEnabled = (state.currentStep === 'Profiles' && items.length > 1) || @@ -340,11 +360,7 @@ function WizardInner({ </Container> {state.currentStep !== 'Details' && ( - <Footer - onNext={onNext} - nextBtnText={currUiStrings.nextBtn} - profile={profile} - /> + <Footer onNext={onNext} nextBtnText={currUiStrings.nextBtn} /> )} <WizardEditListDialog control={editDialogControl} @@ -392,20 +408,15 @@ function Container({children}: {children: React.ReactNode}) { function Footer({ onNext, nextBtnText, - profile, }: { onNext: () => void nextBtnText: string - profile: AppBskyActorDefs.ProfileViewDetailed }) { const t = useTheme() const [state] = useWizardState() const {bottom: bottomInset} = useSafeAreaInsets() - - const items = - state.currentStep === 'Profiles' - ? [profile, ...state.profiles] - : state.feeds + const {currentAccount} = useSession() + const items = state.currentStep === 'Profiles' ? state.profiles : state.feeds const minimumItems = state.currentStep === 'Profiles' ? 8 : 0 @@ -471,19 +482,44 @@ function Footer({ <Text style={[a.text_center, textStyles]}> { items.length < 2 ? ( - <Trans> - It's just you right now! Add more people to your starter pack - by searching above. - </Trans> + currentAccount?.did === items[0].did ? ( + <Trans> + It's just you right now! Add more people to your starter + pack by searching above. + </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 ? ( - <Trans> - <Text style={[a.font_bold, textStyles]}>You</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> + currentAccount?.did === items[0].did ? ( + <Trans> + <Text style={[a.font_bold, textStyles]}>You</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> + ) : ( + <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 ? ( <Trans context="profiles"> <Text style={[a.font_bold, textStyles]} emoji> diff --git a/src/state/queries/actor-starter-packs.ts b/src/state/queries/actor-starter-packs.ts index 670544dfe..d40e05453 100644 --- a/src/state/queries/actor-starter-packs.ts +++ b/src/state/queries/actor-starter-packs.ts @@ -1,15 +1,23 @@ -import {AppBskyGraphGetActorStarterPacks} from '@atproto/api' import { - InfiniteData, - QueryClient, - QueryKey, + type AppBskyGraphGetActorStarterPacks, + type AppBskyGraphGetStarterPacksWithMembership, +} from '@atproto/api' +import { + type InfiniteData, + type QueryClient, + type QueryKey, useInfiniteQuery, } from '@tanstack/react-query' import {useAgent} from '#/state/session' export const RQKEY_ROOT = 'actor-starter-packs' +export const RQKEY_WITH_MEMBERSHIP_ROOT = 'actor-starter-packs-with-membership' export const RQKEY = (did?: string) => [RQKEY_ROOT, did] +export const RQKEY_WITH_MEMBERSHIP = (did?: string) => [ + RQKEY_WITH_MEMBERSHIP_ROOT, + did, +] export function useActorStarterPacksQuery({ did, @@ -42,6 +50,37 @@ export function useActorStarterPacksQuery({ }) } +export function useActorStarterPacksWithMembershipsQuery({ + did, + enabled = true, +}: { + did?: string + enabled?: boolean +}) { + const agent = useAgent() + + return useInfiniteQuery< + AppBskyGraphGetStarterPacksWithMembership.OutputSchema, + Error, + InfiniteData<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({ queryClient, did, @@ -51,3 +90,13 @@ export async function invalidateActorStarterPacksQuery({ }) { await queryClient.invalidateQueries({queryKey: RQKEY(did)}) } + +export async function invalidateActorStarterPacksWithMembershipQuery({ + queryClient, + did, +}: { + queryClient: QueryClient + did: string +}) { + await queryClient.invalidateQueries({queryKey: RQKEY_WITH_MEMBERSHIP(did)}) +} diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 879bf22f9..df8b2e481 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -27,6 +27,7 @@ import {EventStopper} from '#/view/com/util/EventStopper' import * as Toast from '#/view/com/util/Toast' import {Button, ButtonIcon} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' +import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog' import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck' @@ -45,6 +46,7 @@ import { } from '#/components/icons/Person' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' +import {StarterPack} from '#/components/icons/StarterPack' import {EditLiveDialog} from '#/components/live/EditLiveDialog' import {GoLiveDialog} from '#/components/live/GoLiveDialog' import * as Menu from '#/components/Menu' @@ -88,6 +90,7 @@ let ProfileMenu = ({ const blockPromptControl = Prompt.usePromptControl() const loggedOutWarningPromptControl = Prompt.usePromptControl() const goLiveDialogControl = useDialogControl() + const addToStarterPacksDialogControl = useDialogControl() const showLoggedOutWarning = React.useMemo(() => { return ( @@ -301,6 +304,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 testID="profileHeaderDropdownListAddRemoveBtn" label={_(msg`Add to lists`)} onPress={onPressAddRemoveLists}> @@ -440,6 +452,11 @@ let ProfileMenu = ({ </Menu.Outer> </Menu.Root> + <StarterPackDialog + control={addToStarterPacksDialogControl} + targetDid={profile.did} + /> + <ReportDialog control={reportDialogControl} subject={{ |