about summary refs log tree commit diff
path: root/src/screens/Profile/components/ProfileFeedHeader.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Profile/components/ProfileFeedHeader.tsx')
-rw-r--r--src/screens/Profile/components/ProfileFeedHeader.tsx534
1 files changed, 534 insertions, 0 deletions
diff --git a/src/screens/Profile/components/ProfileFeedHeader.tsx b/src/screens/Profile/components/ProfileFeedHeader.tsx
new file mode 100644
index 000000000..0154d535c
--- /dev/null
+++ b/src/screens/Profile/components/ProfileFeedHeader.tsx
@@ -0,0 +1,534 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {AtUri} from '@atproto/api'
+import {msg, Plural, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useHaptics} from '#/lib/haptics'
+import {makeProfileLink} from '#/lib/routes/links'
+import {makeCustomFeedLink} from '#/lib/routes/links'
+import {shareUrl} from '#/lib/sharing'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {toShareUrl} from '#/lib/strings/url-helpers'
+import {logger} from '#/logger'
+import {FeedSourceFeedInfo} from '#/state/queries/feed'
+import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
+import {
+  useAddSavedFeedsMutation,
+  usePreferencesQuery,
+  useRemoveFeedMutation,
+  useUpdateSavedFeedsMutation,
+} from '#/state/queries/preferences'
+import {useSession} from '#/state/session'
+import {formatCount} from '#/view/com/util/numeric/format'
+import * as Toast from '#/view/com/util/Toast'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {Divider} from '#/components/Divider'
+import {useRichText} from '#/components/hooks/useRichText'
+import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
+import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {
+  Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
+  Heart2_Stroke2_Corner0_Rounded as Heart,
+} from '#/components/icons/Heart2'
+import {
+  Pin_Filled_Corner0_Rounded as PinFilled,
+  Pin_Stroke2_Corner0_Rounded as Pin,
+} from '#/components/icons/Pin'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+import * as Layout from '#/components/Layout'
+import {InlineLinkText} from '#/components/Link'
+import {Loader} from '#/components/Loader'
+import * as Menu from '#/components/Menu'
+import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
+import {RichText} from '#/components/RichText'
+import {Text} from '#/components/Typography'
+
+export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
+  const t = useTheme()
+  const {_, i18n} = useLingui()
+  const {hasSession} = useSession()
+  const {gtPhone, gtMobile} = useBreakpoints()
+  const {top} = useSafeAreaInsets()
+  const infoControl = Dialog.useDialogControl()
+  const playHaptic = useHaptics()
+
+  const {data: preferences} = usePreferencesQuery()
+
+  const [likeUri, setLikeUri] = React.useState(info.likeUri || '')
+  const isLiked = !!likeUri
+  const likeCount =
+    isLiked && likeUri ? (info.likeCount || 0) + 1 : info.likeCount || 0
+
+  const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} =
+    useAddSavedFeedsMutation()
+  const {mutateAsync: removeFeed, isPending: isRemovePending} =
+    useRemoveFeedMutation()
+  const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} =
+    useUpdateSavedFeedsMutation()
+
+  const isFeedStateChangePending =
+    isAddSavedFeedPending || isRemovePending || isUpdateFeedPending
+  const savedFeedConfig = preferences?.savedFeeds?.find(
+    f => f.value === info.uri,
+  )
+  const isSaved = Boolean(savedFeedConfig)
+  const isPinned = Boolean(savedFeedConfig?.pinned)
+
+  const onToggleSaved = React.useCallback(async () => {
+    try {
+      playHaptic()
+
+      if (savedFeedConfig) {
+        await removeFeed(savedFeedConfig)
+        Toast.show(_(msg`Removed from your feeds`))
+      } else {
+        await addSavedFeeds([
+          {
+            type: 'feed',
+            value: info.uri,
+            pinned: false,
+          },
+        ])
+        Toast.show(_(msg`Saved to your feeds`))
+      }
+    } catch (err) {
+      Toast.show(
+        _(
+          msg`There was an issue updating your feeds, please check your internet connection and try again.`,
+        ),
+        'xmark',
+      )
+      logger.error('Failed to update feeds', {message: err})
+    }
+  }, [_, playHaptic, info, removeFeed, addSavedFeeds, savedFeedConfig])
+
+  const onTogglePinned = React.useCallback(async () => {
+    try {
+      playHaptic()
+
+      if (savedFeedConfig) {
+        const pinned = !savedFeedConfig.pinned
+        await updateSavedFeeds([
+          {
+            ...savedFeedConfig,
+            pinned,
+          },
+        ])
+
+        if (pinned) {
+          Toast.show(_(msg`Pinned ${info.displayName} to Home`))
+        } else {
+          Toast.show(_(msg`Unpinned ${info.displayName} from Home`))
+        }
+      } else {
+        await addSavedFeeds([
+          {
+            type: 'feed',
+            value: info.uri,
+            pinned: true,
+          },
+        ])
+        Toast.show(_(msg`Pinned ${info.displayName} to Home`))
+      }
+    } catch (e) {
+      Toast.show(_(msg`There was an issue contacting the server`), 'xmark')
+      logger.error('Failed to toggle pinned feed', {message: e})
+    }
+  }, [playHaptic, info, _, savedFeedConfig, updateSavedFeeds, addSavedFeeds])
+
+  return (
+    <>
+      <Layout.Center
+        style={[
+          t.atoms.bg,
+          a.z_10,
+          {paddingTop: top},
+          web([a.sticky, a.z_10, {top: 0}]),
+        ]}>
+        <Layout.Header.Outer>
+          <Layout.Header.BackButton />
+          <Layout.Header.Content align="left">
+            <Button
+              label={_(msg`Open feed info screen`)}
+              style={[
+                a.justify_start,
+                {
+                  paddingVertical: 6,
+                  paddingHorizontal: 8,
+                  paddingRight: 12,
+                },
+              ]}
+              onPress={() => {
+                playHaptic()
+                infoControl.open()
+              }}>
+              {({hovered, pressed}) => (
+                <>
+                  <View
+                    style={[
+                      a.absolute,
+                      a.inset_0,
+                      a.rounded_sm,
+                      a.transition_transform,
+                      t.atoms.bg_contrast_25,
+                      pressed && t.atoms.bg_contrast_50,
+                      hovered && {
+                        transform: [{scaleX: 1.01}, {scaleY: 1.1}],
+                      },
+                    ]}
+                  />
+
+                  <View
+                    style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
+                    {info.avatar && (
+                      <UserAvatar size={32} type="algo" avatar={info.avatar} />
+                    )}
+
+                    <View style={[a.flex_1]}>
+                      <Text
+                        style={[
+                          a.text_md,
+                          a.font_heavy,
+                          a.leading_tight,
+                          gtMobile && a.text_xl,
+                        ]}
+                        numberOfLines={2}>
+                        {info.displayName}
+                      </Text>
+                      <View style={[a.flex_row, {gap: 6}]}>
+                        <Text
+                          style={[
+                            a.flex_shrink,
+                            a.text_xs,
+                            a.leading_snug,
+                            t.atoms.text_contrast_medium,
+                            gtPhone && a.text_sm,
+                          ]}
+                          numberOfLines={1}>
+                          {sanitizeHandle(info.creatorHandle, '@')}
+                        </Text>
+                        <View style={[a.flex_row, a.align_center, {gap: 2}]}>
+                          <HeartFilled
+                            size="xs"
+                            fill={
+                              likeUri
+                                ? t.palette.like
+                                : t.atoms.text_contrast_low.color
+                            }
+                          />
+                          <Text
+                            style={[
+                              a.text_xs,
+                              a.leading_snug,
+                              t.atoms.text_contrast_medium,
+                              gtPhone && a.text_sm,
+                            ]}
+                            numberOfLines={1}>
+                            {formatCount(i18n, likeCount)}
+                          </Text>
+                        </View>
+                      </View>
+                    </View>
+
+                    <ChevronDown
+                      size="md"
+                      fill={t.atoms.text_contrast_low.color}
+                    />
+                  </View>
+                </>
+              )}
+            </Button>
+          </Layout.Header.Content>
+
+          {hasSession && (
+            <Layout.Header.Slot>
+              {isPinned ? (
+                <Menu.Root>
+                  <Menu.Trigger label={_(msg`Open feed options menu`)}>
+                    {({props}) => {
+                      return (
+                        <Button
+                          {...props}
+                          label={_(msg`Open feed options menu`)}
+                          size="small"
+                          variant="ghost"
+                          shape="square"
+                          color="secondary">
+                          <PinFilled size="lg" fill={t.palette.primary_500} />
+                        </Button>
+                      )
+                    }}
+                  </Menu.Trigger>
+
+                  <Menu.Outer>
+                    <Menu.Item
+                      disabled={isFeedStateChangePending}
+                      label={_(msg`Unpin from home`)}
+                      onPress={onTogglePinned}>
+                      <Menu.ItemText>{_(msg`Unpin from home`)}</Menu.ItemText>
+                      <Menu.ItemIcon icon={X} position="right" />
+                    </Menu.Item>
+                    <Menu.Item
+                      disabled={isFeedStateChangePending}
+                      label={
+                        isSaved
+                          ? _(msg`Remove from my feeds`)
+                          : _(msg`Save to my feeds`)
+                      }
+                      onPress={onToggleSaved}>
+                      <Menu.ItemText>
+                        {isSaved
+                          ? _(msg`Remove from my feeds`)
+                          : _(msg`Save to my feeds`)}
+                      </Menu.ItemText>
+                      <Menu.ItemIcon
+                        icon={isSaved ? Trash : Plus}
+                        position="right"
+                      />
+                    </Menu.Item>
+                  </Menu.Outer>
+                </Menu.Root>
+              ) : (
+                <Button
+                  label={_(msg`Pin to Home`)}
+                  size="small"
+                  variant="ghost"
+                  shape="square"
+                  color="secondary"
+                  onPress={onTogglePinned}>
+                  <ButtonIcon icon={Pin} size="lg" />
+                </Button>
+              )}
+            </Layout.Header.Slot>
+          )}
+        </Layout.Header.Outer>
+      </Layout.Center>
+
+      <Dialog.Outer control={infoControl}>
+        <Dialog.Handle />
+        <Dialog.ScrollableInner
+          label={_(msg`Feed menu`)}
+          style={[gtMobile ? {width: 'auto', minWidth: 450} : a.w_full]}>
+          <DialogInner
+            info={info}
+            likeUri={likeUri}
+            setLikeUri={setLikeUri}
+            likeCount={likeCount}
+            isPinned={isPinned}
+            onTogglePinned={onTogglePinned}
+            isFeedStateChangePending={isFeedStateChangePending}
+          />
+        </Dialog.ScrollableInner>
+      </Dialog.Outer>
+    </>
+  )
+}
+
+function DialogInner({
+  info,
+  likeUri,
+  setLikeUri,
+  likeCount,
+  isPinned,
+  onTogglePinned,
+  isFeedStateChangePending,
+}: {
+  info: FeedSourceFeedInfo
+  likeUri: string
+  setLikeUri: (uri: string) => void
+  likeCount: number
+  isPinned: boolean
+  onTogglePinned: () => void
+  isFeedStateChangePending: boolean
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {hasSession} = useSession()
+  const playHaptic = useHaptics()
+  const control = Dialog.useDialogContext()
+  const reportDialogControl = useReportDialogControl()
+  const [rt, loading] = useRichText(info.description.text)
+  const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation()
+  const {mutateAsync: unlikeFeed, isPending: isUnlikePending} =
+    useUnlikeMutation()
+
+  const isLiked = !!likeUri
+  const feedRkey = React.useMemo(() => new AtUri(info.uri).rkey, [info.uri])
+
+  const onToggleLiked = React.useCallback(async () => {
+    try {
+      playHaptic()
+
+      if (isLiked && likeUri) {
+        await unlikeFeed({uri: likeUri})
+        setLikeUri('')
+      } else {
+        const res = await likeFeed({uri: info.uri, cid: info.cid})
+        setLikeUri(res.uri)
+      }
+    } catch (err) {
+      Toast.show(
+        _(
+          msg`There was an issue contacting the server, please check your internet connection and try again.`,
+        ),
+        'xmark',
+      )
+      logger.error('Failed to toggle like', {message: err})
+    }
+  }, [playHaptic, isLiked, likeUri, unlikeFeed, setLikeUri, likeFeed, info, _])
+
+  const onPressShare = React.useCallback(() => {
+    playHaptic()
+    const url = toShareUrl(info.route.href)
+    shareUrl(url)
+  }, [info, playHaptic])
+
+  const onPressReport = React.useCallback(() => {
+    reportDialogControl.open()
+  }, [reportDialogControl])
+
+  return loading ? (
+    <Loader size="xl" />
+  ) : (
+    <View style={[a.gap_md]}>
+      <View style={[a.flex_row, a.align_center, a.gap_md]}>
+        <UserAvatar type="algo" size={48} avatar={info.avatar} />
+
+        <View style={[a.flex_1, a.gap_2xs]}>
+          <Text
+            style={[a.text_2xl, a.font_heavy, a.leading_tight]}
+            numberOfLines={2}>
+            {info.displayName}
+          </Text>
+          <Text
+            style={[a.text_sm, a.leading_tight, t.atoms.text_contrast_medium]}
+            numberOfLines={1}>
+            <Trans>
+              By{' '}
+              <InlineLinkText
+                label={_(msg`View ${info.creatorHandle}'s profile`)}
+                to={makeProfileLink({
+                  did: info.creatorDid,
+                  handle: info.creatorHandle,
+                })}
+                style={[
+                  a.text_sm,
+                  a.leading_tight,
+                  a.underline,
+                  t.atoms.text_contrast_medium,
+                ]}
+                numberOfLines={1}
+                onPress={() => control.close()}>
+                {sanitizeHandle(info.creatorHandle, '@')}
+              </InlineLinkText>
+            </Trans>
+          </Text>
+        </View>
+
+        <Button
+          label={_(msg`Share this feed`)}
+          size="small"
+          variant="ghost"
+          color="secondary"
+          shape="round"
+          onPress={onPressShare}>
+          <ButtonIcon icon={Share} size="lg" />
+        </Button>
+      </View>
+
+      <RichText value={rt} style={[a.text_md, a.leading_snug]} />
+
+      <View style={[a.flex_row, a.gap_sm, a.align_center]}>
+        {typeof likeCount === 'number' && (
+          <InlineLinkText
+            label={_(msg`View users who like this feed`)}
+            to={makeCustomFeedLink(info.creatorDid, feedRkey, 'liked-by')}
+            style={[a.underline, t.atoms.text_contrast_medium]}
+            onPress={() => control.close()}>
+            <Trans>
+              Liked by <Plural value={likeCount} one="# user" other="# users" />
+            </Trans>
+          </InlineLinkText>
+        )}
+      </View>
+
+      {hasSession && (
+        <>
+          <View style={[a.flex_row, a.gap_sm, a.align_center, a.pt_sm]}>
+            <Button
+              disabled={isLikePending || isUnlikePending}
+              label={_(msg`Like feed`)}
+              size="small"
+              variant="solid"
+              color="secondary"
+              onPress={onToggleLiked}
+              style={[a.flex_1]}>
+              {isLiked ? (
+                <HeartFilled size="sm" fill={t.palette.like} />
+              ) : (
+                <ButtonIcon icon={Heart} position="left" />
+              )}
+
+              <ButtonText>
+                {isLiked ? <Trans>Unlike</Trans> : <Trans>Like</Trans>}
+              </ButtonText>
+            </Button>
+            <Button
+              disabled={isFeedStateChangePending}
+              label={isPinned ? _(msg`Unpin feed`) : _(msg`Pin feed`)}
+              size="small"
+              variant="solid"
+              color={isPinned ? 'secondary' : 'primary'}
+              onPress={onTogglePinned}
+              style={[a.flex_1]}>
+              <ButtonText>
+                {isPinned ? <Trans>Unpin feed</Trans> : <Trans>Pin feed</Trans>}
+              </ButtonText>
+              <ButtonIcon icon={Pin} position="right" />
+            </Button>
+          </View>
+
+          <View style={[a.pt_xs, a.gap_lg]}>
+            <Divider />
+
+            <View
+              style={[a.flex_row, a.align_center, a.gap_sm, a.justify_between]}>
+              <Text style={[a.italic, t.atoms.text_contrast_medium]}>
+                Something wrong? Let us know.
+              </Text>
+
+              <Button
+                label={_(msg`Report feed`)}
+                size="small"
+                variant="solid"
+                color="secondary"
+                onPress={onPressReport}>
+                <ButtonText>
+                  <Trans>Report feed</Trans>
+                </ButtonText>
+                <ButtonIcon icon={CircleInfo} position="right" />
+              </Button>
+            </View>
+
+            <ReportDialog
+              control={reportDialogControl}
+              params={{
+                type: 'feedgen',
+                uri: info.uri,
+                cid: info.cid,
+              }}
+            />
+          </View>
+        </>
+      )}
+    </View>
+  )
+}