From 07b028ee668afee13d878c8ff4c579276fd69f6c Mon Sep 17 00:00:00 2001
From: Samuel Newman <mozzius@protonmail.com>
Date: Wed, 9 Jul 2025 10:14:30 +0300
Subject: Fix quote+list card padding (#8623)

* fix quote padding not being pressable

* fix list padding not being pressable

* Fix unnecessary loading of feeds (#8578)

* stop layout shifts in feed loading

* don't load feed data if we already have it

* adjust styles, alf stuff

* remove unused button, massively simplify

* fix layout shifting in notifs

* use feedcard for feed post embeds

* use bold text to match other style

* use Link component rather than jank Pressable

* prevent nested anchors in notifs

* match following text size

* add space between content hider

* Better dead feed handling (#8579)

* add space between content hider

* add handling for feeds that fail to load

* cleanError, in case of network funkiness

* handle deleted lists

* split out missingfeed
---
 src/view/com/feeds/FeedSourceCard.tsx | 451 ++++++++++++----------------------
 1 file changed, 158 insertions(+), 293 deletions(-)

(limited to 'src/view/com/feeds/FeedSourceCard.tsx')

diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 3a658755a..18e2807a8 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -1,50 +1,33 @@
-import React from 'react'
+import {type StyleProp, View, type ViewStyle} from 'react-native'
 import {
-  Linking,
-  Pressable,
-  StyleProp,
-  StyleSheet,
-  View,
-  ViewStyle,
-} from 'react-native'
-import {AtUri} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+  type $Typed,
+  AppBskyFeedDefs,
+  type AppBskyGraphDefs,
+  AtUri,
+} from '@atproto/api'
 import {msg, Plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
-import {usePalette} from '#/lib/hooks/usePalette'
 import {sanitizeHandle} from '#/lib/strings/handles'
-import {s} from '#/lib/styles'
-import {logger} from '#/logger'
-import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
 import {
-  useAddSavedFeedsMutation,
-  usePreferencesQuery,
-  UsePreferencesQueryResponse,
-  useRemoveFeedMutation,
-} from '#/state/queries/preferences'
+  type FeedSourceInfo,
+  hydrateFeedGenerator,
+  hydrateList,
+  useFeedSourceInfoQuery,
+} from '#/state/queries/feed'
 import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
-import * as Toast from '#/view/com/util/Toast'
-import {useTheme} from '#/alf'
-import {atoms as a} from '#/alf'
-import {shouldClickOpenNewTab} from '#/components/Link'
-import * as Prompt from '#/components/Prompt'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, useTheme} from '#/alf'
+import {Link} from '#/components/Link'
 import {RichText} from '#/components/RichText'
-import {Text} from '../util/text/Text'
-import {UserAvatar} from '../util/UserAvatar'
+import {Text} from '#/components/Typography'
+import {MissingFeed} from './MissingFeed'
 
-export function FeedSourceCard({
-  feedUri,
-  style,
-  showSaveBtn = false,
-  showDescription = false,
-  showLikes = false,
-  pinOnSave = false,
-  showMinimalPlaceholder,
-  hideTopBorder,
-}: {
+type FeedSourceCardProps = {
   feedUri: string
+  feedData?:
+    | $Typed<AppBskyFeedDefs.GeneratorView>
+    | $Typed<AppBskyGraphDefs.ListView>
   style?: StyleProp<ViewStyle>
   showSaveBtn?: boolean
   showDescription?: boolean
@@ -52,22 +35,41 @@ export function FeedSourceCard({
   pinOnSave?: boolean
   showMinimalPlaceholder?: boolean
   hideTopBorder?: boolean
-}) {
-  const {data: preferences} = usePreferencesQuery()
-  const {data: feed} = useFeedSourceInfoQuery({uri: feedUri})
+  link?: boolean
+}
+
+export function FeedSourceCard({
+  feedUri,
+  feedData,
+  ...props
+}: FeedSourceCardProps) {
+  if (feedData) {
+    let feed: FeedSourceInfo
+    if (AppBskyFeedDefs.isGeneratorView(feedData)) {
+      feed = hydrateFeedGenerator(feedData)
+    } else {
+      feed = hydrateList(feedData)
+    }
+    return <FeedSourceCardLoaded feedUri={feedUri} feed={feed} {...props} />
+  } else {
+    return <FeedSourceCardWithoutData feedUri={feedUri} {...props} />
+  }
+}
+
+export function FeedSourceCardWithoutData({
+  feedUri,
+  ...props
+}: Omit<FeedSourceCardProps, 'feedData'>) {
+  const {data: feed, error} = useFeedSourceInfoQuery({
+    uri: feedUri,
+  })
 
   return (
     <FeedSourceCardLoaded
       feedUri={feedUri}
       feed={feed}
-      preferences={preferences}
-      style={style}
-      showSaveBtn={showSaveBtn}
-      showDescription={showDescription}
-      showLikes={showLikes}
-      pinOnSave={pinOnSave}
-      showMinimalPlaceholder={showMinimalPlaceholder}
-      hideTopBorder={hideTopBorder}
+      error={error}
+      {...props}
     />
   )
 }
@@ -75,80 +77,26 @@ export function FeedSourceCard({
 export function FeedSourceCardLoaded({
   feedUri,
   feed,
-  preferences,
   style,
-  showSaveBtn = false,
   showDescription = false,
   showLikes = false,
-  pinOnSave = false,
   showMinimalPlaceholder,
   hideTopBorder,
+  link = true,
+  error,
 }: {
   feedUri: string
   feed?: FeedSourceInfo
-  preferences?: UsePreferencesQueryResponse
   style?: StyleProp<ViewStyle>
-  showSaveBtn?: boolean
   showDescription?: boolean
   showLikes?: boolean
-  pinOnSave?: boolean
   showMinimalPlaceholder?: boolean
   hideTopBorder?: boolean
+  link?: boolean
+  error?: unknown
 }) {
   const t = useTheme()
-  const pal = usePalette('default')
   const {_} = useLingui()
-  const removePromptControl = Prompt.usePromptControl()
-  const navigation = useNavigationDeduped()
-
-  const {isPending: isAddSavedFeedPending, mutateAsync: addSavedFeeds} =
-    useAddSavedFeedsMutation()
-  const {isPending: isRemovePending, mutateAsync: removeFeed} =
-    useRemoveFeedMutation()
-
-  const savedFeedConfig = preferences?.savedFeeds?.find(
-    f => f.value === feedUri,
-  )
-  const isSaved = Boolean(savedFeedConfig)
-
-  const onSave = React.useCallback(async () => {
-    if (!feed || isSaved) return
-
-    try {
-      await addSavedFeeds([
-        {
-          type: 'feed',
-          value: feed.uri,
-          pinned: pinOnSave,
-        },
-      ])
-      Toast.show(_(msg`Added to my feeds`))
-    } catch (e) {
-      Toast.show(_(msg`There was an issue contacting your server`), 'xmark')
-      logger.error('Failed to save feed', {message: e})
-    }
-  }, [_, feed, pinOnSave, addSavedFeeds, isSaved])
-
-  const onUnsave = React.useCallback(async () => {
-    if (!savedFeedConfig) return
-
-    try {
-      await removeFeed(savedFeedConfig)
-      // await item.unsave()
-      Toast.show(_(msg`Removed from my feeds`))
-    } catch (e) {
-      Toast.show(_(msg`There was an issue contacting your server`), 'xmark')
-      logger.error('Failed to unsave feed', {message: e})
-    }
-  }, [_, removeFeed, savedFeedConfig])
-
-  const onToggleSaved = React.useCallback(async () => {
-    if (isSaved) {
-      removePromptControl.open()
-    } else {
-      await onSave()
-    }
-  }, [isSaved, removePromptControl, onSave])
 
   /*
    * LOAD STATE
@@ -156,200 +104,117 @@ export function FeedSourceCardLoaded({
    * This state also captures the scenario where a feed can't load for whatever
    * reason.
    */
-  if (!feed || !preferences)
-    return (
-      <View
-        style={[
-          pal.border,
-          {
-            borderTopWidth:
-              showMinimalPlaceholder || hideTopBorder
-                ? 0
-                : StyleSheet.hairlineWidth,
-            flexDirection: 'row',
-            alignItems: 'center',
-            flex: 1,
-            paddingRight: 18,
-          },
-        ]}>
-        {showMinimalPlaceholder ? (
-          <FeedLoadingPlaceholder
-            style={{flex: 1}}
-            showTopBorder={false}
-            showLowerPlaceholder={false}
-          />
-        ) : (
-          <FeedLoadingPlaceholder style={{flex: 1}} showTopBorder={false} />
-        )}
-
-        {showSaveBtn && (
-          <Pressable
-            testID={`feed-${feedUri}-toggleSave`}
-            disabled={isRemovePending}
-            accessibilityRole="button"
-            accessibilityLabel={_(msg`Remove from my feeds`)}
-            accessibilityHint=""
-            onPress={onUnsave}
-            hitSlop={15}
-            style={styles.btn}>
-            <FontAwesomeIcon
-              icon={['far', 'trash-can']}
-              size={19}
-              color={pal.colors.icon}
-            />
-          </Pressable>
-        )}
-      </View>
-    )
+  if (!feed) {
+    if (error) {
+      return (
+        <MissingFeed
+          uri={feedUri}
+          style={style}
+          hideTopBorder={hideTopBorder}
+          error={error}
+        />
+      )
+    }
 
-  return (
-    <>
-      <Pressable
-        testID={`feed-${feed.displayName}`}
-        accessibilityRole="button"
+    return (
+      <FeedLoadingPlaceholder
         style={[
-          styles.container,
-          pal.border,
+          t.atoms.border_contrast_low,
+          !(showMinimalPlaceholder || hideTopBorder) && a.border_t,
+          a.flex_1,
           style,
-          {borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth},
         ]}
-        onPress={e => {
-          const shouldOpenInNewTab = shouldClickOpenNewTab(e)
-          if (feed.type === 'feed') {
-            if (shouldOpenInNewTab) {
-              Linking.openURL(
-                `/profile/${feed.creatorDid}/feed/${new AtUri(feed.uri).rkey}`,
-              )
-            } else {
-              navigation.push('ProfileFeed', {
-                name: feed.creatorDid,
-                rkey: new AtUri(feed.uri).rkey,
-              })
-            }
-          } else if (feed.type === 'list') {
-            if (shouldOpenInNewTab) {
-              Linking.openURL(
-                `/profile/${feed.creatorDid}/lists/${new AtUri(feed.uri).rkey}`,
-              )
-            } else {
-              navigation.push('ProfileList', {
-                name: feed.creatorDid,
-                rkey: new AtUri(feed.uri).rkey,
-              })
-            }
-          }
-        }}
-        key={feed.uri}>
-        <View style={[styles.headerContainer, a.align_center]}>
-          <View style={[s.mr10]}>
-            <UserAvatar type="algo" size={36} avatar={feed.avatar} />
-          </View>
-          <View style={[styles.headerTextContainer]}>
-            <Text emoji style={[pal.text, s.bold]} numberOfLines={1}>
-              {feed.displayName}
-            </Text>
-            <Text style={[pal.textLight]} numberOfLines={1}>
-              {feed.type === 'feed' ? (
-                <Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
-              ) : (
-                <Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
-              )}
-            </Text>
-          </View>
+        showTopBorder={false}
+        showLowerPlaceholder={!showMinimalPlaceholder}
+      />
+    )
+  }
 
-          {showSaveBtn && (
-            <View style={{alignSelf: 'center'}}>
-              <Pressable
-                testID={`feed-${feed.displayName}-toggleSave`}
-                disabled={isAddSavedFeedPending || isRemovePending}
-                accessibilityRole="button"
-                accessibilityLabel={
-                  isSaved
-                    ? _(msg`Remove from my feeds`)
-                    : _(msg`Add to my feeds`)
-                }
-                accessibilityHint=""
-                onPress={onToggleSaved}
-                hitSlop={15}
-                style={styles.btn}>
-                {isSaved ? (
-                  <FontAwesomeIcon
-                    icon={['far', 'trash-can']}
-                    size={19}
-                    color={pal.colors.icon}
-                  />
-                ) : (
-                  <FontAwesomeIcon
-                    icon="plus"
-                    size={18}
-                    color={pal.colors.link}
-                  />
-                )}
-              </Pressable>
-            </View>
-          )}
+  const inner = (
+    <>
+      <View style={[a.flex_row, a.align_center]}>
+        <View style={[a.mr_md]}>
+          <UserAvatar type="algo" size={36} avatar={feed.avatar} />
         </View>
-
-        {showDescription && feed.description ? (
-          <RichText
-            style={[t.atoms.text_contrast_high, styles.description]}
-            value={feed.description}
-            numberOfLines={3}
-          />
-        ) : null}
-
-        {showLikes && feed.type === 'feed' ? (
-          <Text type="sm-medium" style={[pal.text, pal.textLight]}>
-            <Trans>
-              Liked by{' '}
-              <Plural
-                value={feed.likeCount || 0}
-                one="# user"
-                other="# users"
-              />
-            </Trans>
+        <View style={[a.flex_1]}>
+          <Text
+            emoji
+            style={[a.text_sm, a.font_bold, a.leading_snug]}
+            numberOfLines={1}>
+            {feed.displayName}
           </Text>
-        ) : null}
-      </Pressable>
-
-      <Prompt.Basic
-        control={removePromptControl}
-        title={_(msg`Remove from your feeds?`)}
-        description={_(
-          msg`Are you sure you want to remove ${feed.displayName} from your feeds?`,
-        )}
-        onConfirm={onUnsave}
-        confirmButtonCta={_(msg`Remove`)}
-        confirmButtonColor="negative"
-      />
+          <Text
+            style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}
+            numberOfLines={1}>
+            {feed.type === 'feed' ? (
+              <Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
+            ) : (
+              <Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
+            )}
+          </Text>
+        </View>
+      </View>
+      {showDescription && feed.description ? (
+        <RichText
+          style={[t.atoms.text_contrast_high, a.flex_1, a.flex_wrap]}
+          value={feed.description}
+          numberOfLines={3}
+        />
+      ) : null}
+      {showLikes && feed.type === 'feed' ? (
+        <Text
+          style={[
+            a.text_sm,
+            a.font_bold,
+            t.atoms.text_contrast_medium,
+            a.leading_snug,
+          ]}>
+          <Trans>
+            Liked by{' '}
+            <Plural value={feed.likeCount || 0} one="# user" other="# users" />
+          </Trans>
+        </Text>
+      ) : null}
     </>
   )
-}
 
-const styles = StyleSheet.create({
-  container: {
-    paddingHorizontal: 18,
-    paddingVertical: 20,
-    flexDirection: 'column',
-    flex: 1,
-    gap: 14,
-  },
-  border: {
-    borderTopWidth: StyleSheet.hairlineWidth,
-  },
-  headerContainer: {
-    flexDirection: 'row',
-  },
-  headerTextContainer: {
-    flexDirection: 'column',
-    columnGap: 4,
-    flex: 1,
-  },
-  description: {
-    flex: 1,
-    flexWrap: 'wrap',
-  },
-  btn: {
-    paddingVertical: 6,
-  },
-})
+  if (link) {
+    return (
+      <Link
+        testID={`feed-${feed.displayName}`}
+        label={_(
+          feed.type === 'feed'
+            ? msg`${feed.displayName}, a feed by ${sanitizeHandle(feed.creatorHandle, '@')}, liked by ${feed.likeCount || 0}`
+            : msg`${feed.displayName}, a list by ${sanitizeHandle(feed.creatorHandle, '@')}`,
+        )}
+        to={{
+          screen: feed.type === 'feed' ? 'ProfileFeed' : 'ProfileList',
+          params: {name: feed.creatorDid, rkey: new AtUri(feed.uri).rkey},
+        }}
+        style={[
+          a.flex_1,
+          a.p_lg,
+          a.gap_md,
+          !hideTopBorder && !a.border_t,
+          t.atoms.border_contrast_low,
+          style,
+        ]}>
+        {inner}
+      </Link>
+    )
+  } else {
+    return (
+      <View
+        style={[
+          a.flex_1,
+          a.p_lg,
+          a.gap_md,
+          !hideTopBorder && !a.border_t,
+          t.atoms.border_contrast_low,
+          style,
+        ]}>
+        {inner}
+      </View>
+    )
+  }
+}
-- 
cgit 1.4.1

