about summary refs log tree commit diff
path: root/src/screens/StarterPack/StarterPackScreen.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/StarterPack/StarterPackScreen.tsx')
-rw-r--r--src/screens/StarterPack/StarterPackScreen.tsx627
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>
+  )
+}