about summary refs log tree commit diff
path: root/src/screens
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens')
-rw-r--r--src/screens/Profile/ProfileFeed/index.tsx227
-rw-r--r--src/screens/Profile/components/ProfileFeedHeader.tsx534
2 files changed, 761 insertions, 0 deletions
diff --git a/src/screens/Profile/ProfileFeed/index.tsx b/src/screens/Profile/ProfileFeed/index.tsx
new file mode 100644
index 000000000..7d48b5ac1
--- /dev/null
+++ b/src/screens/Profile/ProfileFeed/index.tsx
@@ -0,0 +1,227 @@
+import React, {useCallback, useMemo} from 'react'
+import {StyleSheet, View} from 'react-native'
+import {useAnimatedRef} from 'react-native-reanimated'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useIsFocused, useNavigation} from '@react-navigation/native'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useSetTitle} from '#/lib/hooks/useSetTitle'
+import {ComposeIcon2} from '#/lib/icons'
+import {CommonNavigatorParams} from '#/lib/routes/types'
+import {NavigationProp} from '#/lib/routes/types'
+import {makeRecordUri} from '#/lib/strings/url-helpers'
+import {s} from '#/lib/styles'
+import {isNative} from '#/platform/detection'
+import {listenSoftReset} from '#/state/events'
+import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
+import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
+import {FeedDescriptor} from '#/state/queries/post-feed'
+import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
+import {
+  usePreferencesQuery,
+  UsePreferencesQueryResponse,
+} from '#/state/queries/preferences'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {truncateAndInvalidate} from '#/state/queries/util'
+import {useSession} from '#/state/session'
+import {useComposerControls} from '#/state/shell/composer'
+import {PostFeed} from '#/view/com/posts/PostFeed'
+import {EmptyState} from '#/view/com/util/EmptyState'
+import {FAB} from '#/view/com/util/fab/FAB'
+import {Button} from '#/view/com/util/forms/Button'
+import {ListRef} from '#/view/com/util/List'
+import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
+import {LoadingScreen} from '#/view/com/util/LoadingScreen'
+import {Text} from '#/view/com/util/text/Text'
+import {ProfileFeedHeader} from '#/screens/Profile/components/ProfileFeedHeader'
+import * as Layout from '#/components/Layout'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'>
+export function ProfileFeedScreen(props: Props) {
+  const {rkey, name: handleOrDid} = props.route.params
+
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const navigation = useNavigation<NavigationProp>()
+
+  const uri = useMemo(
+    () => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey),
+    [rkey, handleOrDid],
+  )
+  const {error, data: resolvedUri} = useResolveUriQuery(uri)
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
+  }, [navigation])
+
+  if (error) {
+    return (
+      <Layout.Screen testID="profileFeedScreenError">
+        <Layout.Content>
+          <View style={[pal.view, pal.border, styles.notFoundContainer]}>
+            <Text type="title-lg" style={[pal.text, s.mb10]}>
+              <Trans>Could not load feed</Trans>
+            </Text>
+            <Text type="md" style={[pal.text, s.mb20]}>
+              {error.toString()}
+            </Text>
+
+            <View style={{flexDirection: 'row'}}>
+              <Button
+                type="default"
+                accessibilityLabel={_(msg`Go back`)}
+                accessibilityHint={_(msg`Returns to previous page`)}
+                onPress={onPressBack}
+                style={{flexShrink: 1}}>
+                <Text type="button" style={pal.text}>
+                  <Trans>Go Back</Trans>
+                </Text>
+              </Button>
+            </View>
+          </View>
+        </Layout.Content>
+      </Layout.Screen>
+    )
+  }
+
+  return resolvedUri ? (
+    <Layout.Screen noInsetTop>
+      <ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} />
+    </Layout.Screen>
+  ) : (
+    <Layout.Screen>
+      <LoadingScreen />
+    </Layout.Screen>
+  )
+}
+
+function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
+  const {data: preferences} = usePreferencesQuery()
+  const {data: info} = useFeedSourceInfoQuery({uri: feedUri})
+
+  if (!preferences || !info) {
+    return <LoadingScreen />
+  }
+
+  return (
+    <ProfileFeedScreenInner
+      preferences={preferences}
+      feedInfo={info as FeedSourceFeedInfo}
+    />
+  )
+}
+
+export function ProfileFeedScreenInner({
+  feedInfo,
+}: {
+  preferences: UsePreferencesQueryResponse
+  feedInfo: FeedSourceFeedInfo
+}) {
+  const {_} = useLingui()
+  const {hasSession} = useSession()
+  const {openComposer} = useComposerControls()
+  const isScreenFocused = useIsFocused()
+
+  useSetTitle(feedInfo?.displayName)
+
+  const feed = `feedgen|${feedInfo.uri}` as FeedDescriptor
+
+  const [hasNew, setHasNew] = React.useState(false)
+  const [isScrolledDown, setIsScrolledDown] = React.useState(false)
+  const queryClient = useQueryClient()
+  const feedFeedback = useFeedFeedback(feed, hasSession)
+  const scrollElRef = useAnimatedRef() as ListRef
+
+  const onScrollToTop = useCallback(() => {
+    scrollElRef.current?.scrollToOffset({
+      animated: isNative,
+      offset: 0, // -headerHeight,
+    })
+    truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
+    setHasNew(false)
+  }, [scrollElRef, queryClient, feed, setHasNew])
+
+  React.useEffect(() => {
+    if (!isScreenFocused) {
+      return
+    }
+    return listenSoftReset(onScrollToTop)
+  }, [onScrollToTop, isScreenFocused])
+
+  const renderPostsEmpty = useCallback(() => {
+    return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} />
+  }, [_])
+
+  return (
+    <>
+      <ProfileFeedHeader info={feedInfo} />
+
+      <FeedFeedbackProvider value={feedFeedback}>
+        <PostFeed
+          feed={feed}
+          pollInterval={60e3}
+          disablePoll={hasNew}
+          onHasNew={setHasNew}
+          scrollElRef={scrollElRef}
+          onScrolledDownChange={setIsScrolledDown}
+          renderEmptyState={renderPostsEmpty}
+        />
+      </FeedFeedbackProvider>
+
+      {(isScrolledDown || hasNew) && (
+        <LoadLatestBtn
+          onPress={onScrollToTop}
+          label={_(msg`Load new posts`)}
+          showIndicator={hasNew}
+        />
+      )}
+
+      {hasSession && (
+        <FAB
+          testID="composeFAB"
+          onPress={() => openComposer({})}
+          icon={
+            <ComposeIcon2
+              strokeWidth={1.5}
+              size={29}
+              style={{color: 'white'}}
+            />
+          }
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`New post`)}
+          accessibilityHint=""
+        />
+      )}
+    </>
+  )
+}
+
+const styles = StyleSheet.create({
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 6,
+    paddingVertical: 7,
+    paddingHorizontal: 14,
+    borderRadius: 50,
+    marginLeft: 6,
+  },
+  notFoundContainer: {
+    margin: 10,
+    paddingHorizontal: 18,
+    paddingVertical: 14,
+    borderRadius: 6,
+  },
+  aboutSectionContainer: {
+    paddingVertical: 4,
+    paddingHorizontal: 16,
+    gap: 12,
+  },
+})
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>
+  )
+}