diff options
author | Hailey <me@haileyok.com> | 2024-06-21 21:38:04 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-06-21 21:38:04 -0700 |
commit | f089f4578131e83cd177b7809ce0f7b75779dfdc (patch) | |
tree | 51978aede2040fb8dc319f0749d3de77c7811fbe /src/screens/StarterPack/StarterPackScreen.tsx | |
parent | 35f64535cb8dfa0fe46e740a6398f3b991ecfbc7 (diff) | |
download | voidsky-f089f4578131e83cd177b7809ce0f7b75779dfdc.tar.zst |
Starter Packs (#4332)
Co-authored-by: Dan Abramov <dan.abramov@gmail.com> Co-authored-by: Paul Frazee <pfrazee@gmail.com> Co-authored-by: Eric Bailey <git@esb.lol> Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src/screens/StarterPack/StarterPackScreen.tsx')
-rw-r--r-- | src/screens/StarterPack/StarterPackScreen.tsx | 627 |
1 files changed, 627 insertions, 0 deletions
diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx new file mode 100644 index 000000000..46ce25236 --- /dev/null +++ b/src/screens/StarterPack/StarterPackScreen.tsx @@ -0,0 +1,627 @@ +import React from 'react' +import {View} from 'react-native' +import {Image} from 'expo-image' +import { + AppBskyGraphDefs, + AppBskyGraphGetList, + AppBskyGraphStarterpack, + AtUri, + ModerationOpts, +} from '@atproto/api' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import { + InfiniteData, + UseInfiniteQueryResult, + useQueryClient, +} from '@tanstack/react-query' + +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs' +import {HITSLOP_20} from 'lib/constants' +import {makeProfileLink, makeStarterPackLink} from 'lib/routes/links' +import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types' +import {logEvent} from 'lib/statsig/statsig' +import {getStarterPackOgCard} from 'lib/strings/starter-pack' +import {isWeb} from 'platform/detection' +import {useModerationOpts} from 'state/preferences/moderation-opts' +import {RQKEY, useListMembersQuery} from 'state/queries/list-members' +import {useResolveDidQuery} from 'state/queries/resolve-uri' +import {useShortenLink} from 'state/queries/shorten-link' +import {useStarterPackQuery} from 'state/queries/starter-packs' +import {useAgent, useSession} from 'state/session' +import * as Toast from '#/view/com/util/Toast' +import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' +import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' +import {CenteredView} from 'view/com/util/Views' +import {bulkWriteFollows} from '#/screens/Onboarding/util' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {ListMaybePlaceholder} from '#/components/Lists' +import {Loader} from '#/components/Loader' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' +import {FeedsList} from '#/components/StarterPack/Main/FeedsList' +import {ProfilesList} from '#/components/StarterPack/Main/ProfilesList' +import {QrCodeDialog} from '#/components/StarterPack/QrCodeDialog' +import {ShareDialog} from '#/components/StarterPack/ShareDialog' +import {Text} from '#/components/Typography' + +type StarterPackScreeProps = NativeStackScreenProps< + CommonNavigatorParams, + 'StarterPack' +> + +export function StarterPackScreen({route}: StarterPackScreeProps) { + const {_} = useLingui() + const {currentAccount} = useSession() + + const {name, rkey} = route.params + const moderationOpts = useModerationOpts() + const { + data: did, + isLoading: isLoadingDid, + isError: isErrorDid, + } = useResolveDidQuery(name) + const { + data: starterPack, + isLoading: isLoadingStarterPack, + isError: isErrorStarterPack, + } = useStarterPackQuery({did, rkey}) + const listMembersQuery = useListMembersQuery(starterPack?.list?.uri, 50) + + const isValid = + starterPack && + (starterPack.list || starterPack?.creator?.did === currentAccount?.did) && + AppBskyGraphDefs.validateStarterPackView(starterPack) && + AppBskyGraphStarterpack.validateRecord(starterPack.record) + + if (!did || !starterPack || !isValid || !moderationOpts) { + return ( + <ListMaybePlaceholder + isLoading={ + isLoadingDid || + isLoadingStarterPack || + listMembersQuery.isLoading || + !moderationOpts + } + isError={isErrorDid || isErrorStarterPack || !isValid} + errorMessage={_(msg`That starter pack could not be found.`)} + emptyMessage={_(msg`That starter pack could not be found.`)} + /> + ) + } + + if (!starterPack.list && starterPack.creator.did === currentAccount?.did) { + return <InvalidStarterPack rkey={rkey} /> + } + + return ( + <StarterPackScreenInner + starterPack={starterPack} + routeParams={route.params} + listMembersQuery={listMembersQuery} + moderationOpts={moderationOpts} + /> + ) +} + +function StarterPackScreenInner({ + starterPack, + routeParams, + listMembersQuery, + moderationOpts, +}: { + starterPack: AppBskyGraphDefs.StarterPackView + routeParams: StarterPackScreeProps['route']['params'] + listMembersQuery: UseInfiniteQueryResult< + InfiniteData<AppBskyGraphGetList.OutputSchema> + > + moderationOpts: ModerationOpts +}) { + const tabs = [ + ...(starterPack.list ? ['People'] : []), + ...(starterPack.feeds?.length ? ['Feeds'] : []), + ] + + const qrCodeDialogControl = useDialogControl() + const shareDialogControl = useDialogControl() + + const shortenLink = useShortenLink() + const [link, setLink] = React.useState<string>() + const [imageLoaded, setImageLoaded] = React.useState(false) + + const onOpenShareDialog = React.useCallback(() => { + const rkey = new AtUri(starterPack.uri).rkey + shortenLink(makeStarterPackLink(starterPack.creator.did, rkey)).then( + res => { + setLink(res.url) + }, + ) + Image.prefetch(getStarterPackOgCard(starterPack)) + .then(() => { + setImageLoaded(true) + }) + .catch(() => { + setImageLoaded(true) + }) + shareDialogControl.open() + }, [shareDialogControl, shortenLink, starterPack]) + + React.useEffect(() => { + if (routeParams.new) { + onOpenShareDialog() + } + }, [onOpenShareDialog, routeParams.new, shareDialogControl]) + + return ( + <CenteredView style={[a.h_full_vh]}> + <View style={isWeb ? {minHeight: '100%'} : {height: '100%'}}> + <PagerWithHeader + items={tabs} + isHeaderReady={true} + renderHeader={() => ( + <Header + starterPack={starterPack} + routeParams={routeParams} + onOpenShareDialog={onOpenShareDialog} + /> + )}> + {starterPack.list != null + ? ({headerHeight, scrollElRef}) => ( + <ProfilesList + key={0} + // Validated above + listUri={starterPack!.list!.uri} + headerHeight={headerHeight} + // @ts-expect-error + scrollElRef={scrollElRef} + listMembersQuery={listMembersQuery} + moderationOpts={moderationOpts} + /> + ) + : null} + {starterPack.feeds != null + ? ({headerHeight, scrollElRef}) => ( + <FeedsList + key={1} + // @ts-expect-error ? + feeds={starterPack?.feeds} + headerHeight={headerHeight} + // @ts-expect-error + scrollElRef={scrollElRef} + /> + ) + : null} + </PagerWithHeader> + </View> + + <QrCodeDialog + control={qrCodeDialogControl} + starterPack={starterPack} + link={link} + /> + <ShareDialog + control={shareDialogControl} + qrDialogControl={qrCodeDialogControl} + starterPack={starterPack} + link={link} + imageLoaded={imageLoaded} + /> + </CenteredView> + ) +} + +function Header({ + starterPack, + routeParams, + onOpenShareDialog, +}: { + starterPack: AppBskyGraphDefs.StarterPackView + routeParams: StarterPackScreeProps['route']['params'] + onOpenShareDialog: () => void +}) { + const {_} = useLingui() + const t = useTheme() + const {currentAccount} = useSession() + const agent = useAgent() + const queryClient = useQueryClient() + + const [isProcessing, setIsProcessing] = React.useState(false) + + const {record, creator} = starterPack + const isOwn = creator?.did === currentAccount?.did + const joinedAllTimeCount = starterPack.joinedAllTimeCount ?? 0 + + const onFollowAll = async () => { + if (!starterPack.list) return + + setIsProcessing(true) + + try { + const list = await agent.app.bsky.graph.getList({ + list: starterPack.list.uri, + }) + const dids = list.data.items + .filter(li => !li.subject.viewer?.following) + .map(li => li.subject.did) + + await bulkWriteFollows(agent, dids) + + await queryClient.refetchQueries({ + queryKey: RQKEY(starterPack.list.uri), + }) + + logEvent('starterPack:followAll', { + logContext: 'StarterPackProfilesList', + starterPack: starterPack.uri, + count: dids.length, + }) + Toast.show(_(msg`All accounts have been followed!`)) + } catch (e) { + Toast.show(_(msg`An error occurred while trying to follow all`)) + } finally { + setIsProcessing(false) + } + } + + if (!AppBskyGraphStarterpack.isRecord(record)) { + return null + } + + return ( + <> + <ProfileSubpageHeader + isLoading={false} + href={makeProfileLink(creator)} + title={record.name} + isOwner={isOwn} + avatar={undefined} + creator={creator} + avatarType="starter-pack"> + <View style={[a.flex_row, a.gap_sm, a.align_center]}> + {isOwn ? ( + <Button + label={_(msg`Share this starter pack`)} + hitSlop={HITSLOP_20} + variant="solid" + color="primary" + size="small" + onPress={onOpenShareDialog}> + <ButtonText> + <Trans>Share</Trans> + </ButtonText> + </Button> + ) : ( + <Button + label={_(msg`Follow all`)} + variant="solid" + color="primary" + size="small" + disabled={isProcessing} + onPress={onFollowAll}> + <ButtonText> + <Trans>Follow all</Trans> + {isProcessing && <Loader size="xs" />} + </ButtonText> + </Button> + )} + <OverflowMenu + routeParams={routeParams} + starterPack={starterPack} + onOpenShareDialog={onOpenShareDialog} + /> + </View> + </ProfileSubpageHeader> + {record.description || joinedAllTimeCount >= 25 ? ( + <View style={[a.px_lg, a.pt_md, a.pb_sm, a.gap_md]}> + {record.description ? ( + <Text style={[a.text_md, a.leading_snug]}> + {record.description} + </Text> + ) : null} + {joinedAllTimeCount >= 25 ? ( + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + <FontAwesomeIcon + icon="arrow-trend-up" + size={12} + color={t.atoms.text_contrast_medium.color} + /> + <Text + style={[a.font_bold, a.text_sm, t.atoms.text_contrast_medium]}> + <Trans> + {starterPack.joinedAllTimeCount || 0} people have used this + starter pack! + </Trans> + </Text> + </View> + ) : null} + </View> + ) : null} + </> + ) +} + +function OverflowMenu({ + starterPack, + routeParams, + onOpenShareDialog, +}: { + starterPack: AppBskyGraphDefs.StarterPackView + routeParams: StarterPackScreeProps['route']['params'] + onOpenShareDialog: () => void +}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const {currentAccount} = useSession() + const reportDialogControl = useReportDialogControl() + const deleteDialogControl = useDialogControl() + const navigation = useNavigation<NavigationProp>() + + const { + mutate: deleteStarterPack, + isPending: isDeletePending, + error: deleteError, + } = useDeleteStarterPackMutation({ + onSuccess: () => { + logEvent('starterPack:delete', {}) + deleteDialogControl.close(() => { + if (navigation.canGoBack()) { + navigation.popToTop() + } else { + navigation.navigate('Home') + } + }) + }, + onError: e => { + logger.error('Failed to delete starter pack', {safeMessage: e}) + }, + }) + + const isOwn = starterPack.creator.did === currentAccount?.did + + const onDeleteStarterPack = async () => { + if (!starterPack.list) { + logger.error(`Unable to delete starterpack because list is missing`) + return + } + + deleteStarterPack({ + rkey: routeParams.rkey, + listUri: starterPack.list.uri, + }) + logEvent('starterPack:delete', {}) + } + + return ( + <> + <Menu.Root> + <Menu.Trigger label={_(msg`Repost or quote post`)}> + {({props}) => ( + <Button + {...props} + testID="headerDropdownBtn" + label={_(msg`Open starter pack menu`)} + hitSlop={HITSLOP_20} + variant="solid" + color="secondary" + size="small" + shape="round"> + <ButtonIcon icon={Ellipsis} /> + </Button> + )} + </Menu.Trigger> + <Menu.Outer style={{minWidth: 170}}> + {isOwn ? ( + <> + <Menu.Item + label={_(msg`Edit starter pack`)} + testID="editStarterPackLinkBtn" + onPress={() => { + navigation.navigate('StarterPackEdit', { + rkey: routeParams.rkey, + }) + }}> + <Menu.ItemText> + <Trans>Edit</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Pencil} position="right" /> + </Menu.Item> + <Menu.Item + label={_(msg`Delete starter pack`)} + testID="deleteStarterPackBtn" + onPress={() => { + deleteDialogControl.open() + }}> + <Menu.ItemText> + <Trans>Delete</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Trash} position="right" /> + </Menu.Item> + </> + ) : ( + <> + <Menu.Group> + <Menu.Item + label={_(msg`Share`)} + testID="shareStarterPackLinkBtn" + onPress={onOpenShareDialog}> + <Menu.ItemText> + <Trans>Share link</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ArrowOutOfBox} position="right" /> + </Menu.Item> + </Menu.Group> + + <Menu.Item + label={_(msg`Report starter pack`)} + onPress={reportDialogControl.open}> + <Menu.ItemText> + <Trans>Report starter pack</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={CircleInfo} position="right" /> + </Menu.Item> + </> + )} + </Menu.Outer> + </Menu.Root> + + {starterPack.list && ( + <ReportDialog + control={reportDialogControl} + params={{ + type: 'starterpack', + uri: starterPack.uri, + cid: starterPack.cid, + }} + /> + )} + + <Prompt.Outer control={deleteDialogControl}> + <Prompt.TitleText> + <Trans>Delete starter pack?</Trans> + </Prompt.TitleText> + <Prompt.DescriptionText> + <Trans>Are you sure you want delete this starter pack?</Trans> + </Prompt.DescriptionText> + {deleteError && ( + <View + style={[ + a.flex_row, + a.gap_sm, + a.rounded_sm, + a.p_md, + a.mb_lg, + a.border, + t.atoms.border_contrast_medium, + t.atoms.bg_contrast_25, + ]}> + <View style={[a.flex_1, a.gap_2xs]}> + <Text style={[a.font_bold]}> + <Trans>Unable to delete</Trans> + </Text> + <Text style={[a.leading_snug]}>{cleanError(deleteError)}</Text> + </View> + <CircleInfo size="sm" fill={t.palette.negative_400} /> + </View> + )} + <Prompt.Actions> + <Button + variant="solid" + color="negative" + size={gtMobile ? 'small' : 'medium'} + label={_(msg`Yes, delete this starter pack`)} + onPress={onDeleteStarterPack}> + <ButtonText> + <Trans>Delete</Trans> + </ButtonText> + {isDeletePending && <ButtonIcon icon={Loader} />} + </Button> + <Prompt.Cancel /> + </Prompt.Actions> + </Prompt.Outer> + </> + ) +} + +function InvalidStarterPack({rkey}: {rkey: string}) { + const {_} = useLingui() + const t = useTheme() + const navigation = useNavigation<NavigationProp>() + const {gtMobile} = useBreakpoints() + const [isProcessing, setIsProcessing] = React.useState(false) + + const goBack = () => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.replace('Home') + } + } + + const {mutate: deleteStarterPack} = useDeleteStarterPackMutation({ + onSuccess: () => { + setIsProcessing(false) + goBack() + }, + onError: e => { + setIsProcessing(false) + logger.error('Failed to delete invalid starter pack', {safeMessage: e}) + Toast.show(_(msg`Failed to delete starter pack`)) + }, + }) + + return ( + <CenteredView + style={[ + a.flex_1, + a.align_center, + a.gap_5xl, + !gtMobile && a.justify_between, + t.atoms.border_contrast_low, + {paddingTop: 175, paddingBottom: 110}, + ]} + sideBorders={true}> + <View style={[a.w_full, a.align_center, a.gap_lg]}> + <Text style={[a.font_bold, a.text_3xl]}> + <Trans>Starter pack is invalid</Trans> + </Text> + <Text + style={[ + a.text_md, + a.text_center, + t.atoms.text_contrast_high, + {lineHeight: 1.4}, + gtMobile ? {width: 450} : [a.w_full, a.px_lg], + ]}> + <Trans> + The starter pack that you are trying to view is invalid. You may + delete this starter pack instead. + </Trans> + </Text> + </View> + <View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}> + <Button + variant="solid" + color="primary" + label={_(msg`Delete starter pack`)} + size="large" + style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]} + disabled={isProcessing} + onPress={() => { + setIsProcessing(true) + deleteStarterPack({rkey}) + }}> + <ButtonText> + <Trans>Delete</Trans> + </ButtonText> + {isProcessing && <Loader size="xs" color="white" />} + </Button> + <Button + variant="solid" + color="secondary" + label={_(msg`Return to previous page`)} + size="large" + style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]} + disabled={isProcessing} + onPress={goBack}> + <ButtonText> + <Trans>Go Back</Trans> + </ButtonText> + </Button> + </View> + </CenteredView> + ) +} |