Files
bsky-social-app/src/components/dialogs/StarterPackDialog.tsx
T
2026-03-18 18:29:22 -05:00

395 lines
11 KiB
TypeScript

import {useCallback} from 'react'
import {View} from 'react-native'
import {
type AppBskyGraphGetStarterPacksWithMembership,
AppBskyGraphStarterpack,
} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {type NavigationProp} from '#/lib/routes/types'
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useActorStarterPacksWithMembershipsQuery} from '#/state/queries/actor-starter-packs'
import {
useListMembershipAddMutation,
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {StarterPack} from '#/components/icons/StarterPack'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import * as bsky from '#/types/bsky'
type StarterPackWithMembership =
AppBskyGraphGetStarterPacksWithMembership.StarterPackWithMembership
export type StarterPackDialogProps = {
control: Dialog.DialogControlProps
targetDid: string
enabled?: boolean
}
export function StarterPackDialog({
control,
targetDid,
enabled,
}: StarterPackDialogProps) {
const navigation = useNavigation<NavigationProp>()
const requireEmailVerification = useRequireEmailVerification()
const navToWizard = 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} nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<StarterPackList
onStartWizard={wrappedNavToWizard}
targetDid={targetDid}
enabled={enabled}
/>
</Dialog.Outer>
)
}
function Empty({onStartWizard}: {onStartWizard: () => void}) {
const {_} = useLingui()
const t = useTheme()
return (
<View style={[a.gap_2xl, {paddingTop: IS_WEB ? 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={PlusIcon} />
</Button>
</View>
</View>
)
}
function StarterPackList({
onStartWizard,
targetDid,
enabled,
}: {
onStartWizard: () => void
targetDid: string
enabled?: boolean
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const {data: subject} = useProfileQuery({did: targetDid})
const {
data,
isError,
isLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useActorStarterPacksWithMembershipsQuery({did: targetDid, enabled})
const membershipItems =
data?.pages.flatMap(page => page.starterPacksWithMembership) || []
const onEndReached = 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 = useCallback(
({item}: {item: StarterPackWithMembership}) => (
<StarterPackItem
starterPackWithMembership={item}
targetDid={targetDid}
subject={subject}
/>
),
[targetDid, subject],
)
const onClose = useCallback(() => {
control.close()
}, [control])
const listHeader = (
<>
<View
style={[
a.justify_between,
a.align_center,
a.flex_row,
a.pb_lg,
native(a.pt_lg),
]}>
<Text style={[a.text_lg, a.font_semi_bold]}>
<Trans>Add to starter packs</Trans>
</Text>
<Button
label={_(msg`Close`)}
onPress={onClose}
variant="ghost"
color="secondary"
size="small"
shape="round"
style={{margin: -8}}>
<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_semi_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={PlusIcon} />
</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
}
onEndReached={onEndReached}
onEndReachedThreshold={0.1}
ListHeaderComponent={listHeader}
ListEmptyComponent={<Empty onStartWizard={onStartWizard} />}
style={platform({
web: [a.px_2xl, {minHeight: 500}],
native: [a.px_2xl, a.pt_lg],
})}
/>
)
}
function StarterPackItem({
starterPackWithMembership,
targetDid,
subject,
}: {
starterPackWithMembership: StarterPackWithMembership
targetDid: string
subject?: bsky.profile.AnyProfileView
}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const {currentAccount} = useSession()
const isSelf = subject?.did === currentAccount?.did
const starterPack = starterPackWithMembership.starterPack
const isInPack = !!starterPackWithMembership.listItem
const {mutate: addMembership, isPending: isPendingAdd} =
useListMembershipAddMutation({
subject,
onSuccess: () => {
Toast.show(_(msg`Added to starter pack`))
},
onError: err => {
if (!isNetworkError(err)) {
logger.error('Failed to add to starter pack', {safeMessage: err})
}
Toast.show(_(msg`Failed to add to starter pack`), {type: 'error'})
},
})
const {mutate: removeMembership, isPending: isPendingRemove} =
useListMembershipRemoveMutation({
onSuccess: () => {
Toast.show(_(msg`Removed from starter pack`))
},
onError: err => {
if (!isNetworkError(err)) {
logger.error('Failed to remove from starter pack', {safeMessage: err})
}
Toast.show(_(msg`Failed to remove from starter pack`), {type: 'error'})
},
})
const isPending = isPendingAdd || isPendingRemove
const handleToggleMembership = () => {
if (!starterPack.list?.uri || isPending) return
const listUri = starterPack.list.uri
const starterPackUri = starterPack.uri
if (!isInPack) {
addMembership({
listUri: listUri,
actorDid: targetDid,
})
ax.metric('starterPack:addUser', {starterPack: starterPackUri})
} else {
if (!starterPackWithMembership.listItem?.uri) {
console.error('Cannot remove: missing membership URI')
return
}
removeMembership({
listUri: listUri,
actorDid: targetDid,
membershipUri: starterPackWithMembership.listItem.uri,
})
ax.metric('starterPack:removeUser', {starterPack: starterPackUri})
}
}
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_semi_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={24}
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_subtle'}
size="tiny"
disabled={isPending || isSelf}
onPress={handleToggleMembership}>
{isPending && <ButtonIcon icon={Loader} />}
<ButtonText>
{isSelf ? (
<Trans>Owner</Trans>
) : isInPack ? (
<Trans>Remove</Trans>
) : (
<Trans>Add</Trans>
)}
</ButtonText>
</Button>
</View>
)
}