about summary refs log tree commit diff
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-07-09 10:14:30 +0300
committerGitHub <noreply@github.com>2025-07-09 00:14:30 -0700
commit07b028ee668afee13d878c8ff4c579276fd69f6c (patch)
tree5ca697be20c174fefefb7257be4cd42571896daa
parent1fcd313424c431b9a8ac48e7234d2dd52f6dcb6a (diff)
downloadvoidsky-07b028ee668afee13d878c8ff4c579276fd69f6c.tar.zst
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
-rw-r--r--src/components/Divider.tsx11
-rw-r--r--src/components/FeedCard.tsx2
-rw-r--r--src/components/ListCard.tsx12
-rw-r--r--src/components/Post/Embed/FeedEmbed.tsx43
-rw-r--r--src/components/Post/Embed/ListEmbed.tsx17
-rw-r--r--src/components/Post/Embed/index.tsx106
-rw-r--r--src/components/moderation/ContentHider.tsx13
-rw-r--r--src/view/com/feeds/FeedSourceCard.tsx451
-rw-r--r--src/view/com/feeds/MissingFeed.tsx222
-rw-r--r--src/view/com/notifications/NotificationFeedItem.tsx3
-rw-r--r--src/view/com/util/LoadingPlaceholder.tsx21
-rw-r--r--src/view/screens/SavedFeeds.tsx23
12 files changed, 496 insertions, 428 deletions
diff --git a/src/components/Divider.tsx b/src/components/Divider.tsx
index e4891aacb..ec7a7356a 100644
--- a/src/components/Divider.tsx
+++ b/src/components/Divider.tsx
@@ -1,18 +1,11 @@
 import {View} from 'react-native'
 
-import {atoms as a, flatten, useTheme, ViewStyleProp} from '#/alf'
+import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
 
 export function Divider({style}: ViewStyleProp) {
   const t = useTheme()
 
   return (
-    <View
-      style={[
-        a.w_full,
-        a.border_t,
-        t.atoms.border_contrast_low,
-        flatten(style),
-      ]}
-    />
+    <View style={[a.w_full, a.border_t, t.atoms.border_contrast_low, style]} />
   )
 }
diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx
index f94692e5b..33905dacd 100644
--- a/src/components/FeedCard.tsx
+++ b/src/components/FeedCard.tsx
@@ -214,7 +214,7 @@ export function DescriptionPlaceholder() {
 export function Likes({count}: {count: number}) {
   const t = useTheme()
   return (
-    <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
+    <Text style={[a.text_sm, t.atoms.text_contrast_medium, a.font_bold]}>
       <Trans>
         Liked by <Plural value={count || 0} one="# user" other="# users" />
       </Trans>
diff --git a/src/components/ListCard.tsx b/src/components/ListCard.tsx
index 30156ee0d..e34830ea9 100644
--- a/src/components/ListCard.tsx
+++ b/src/components/ListCard.tsx
@@ -1,10 +1,10 @@
 import React from 'react'
 import {View} from 'react-native'
 import {
-  AppBskyGraphDefs,
+  type AppBskyGraphDefs,
   AtUri,
   moderateUserList,
-  ModerationUI,
+  type ModerationUI,
 } from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -22,10 +22,10 @@ import {
   Outer,
   SaveButton,
 } from '#/components/FeedCard'
-import {Link as InternalLink, LinkProps} from '#/components/Link'
+import {Link as InternalLink, type LinkProps} from '#/components/Link'
 import * as Hider from '#/components/moderation/Hider'
 import {Text} from '#/components/Typography'
-import * as bsky from '#/types/bsky'
+import type * as bsky from '#/types/bsky'
 
 /*
  * This component is based on `FeedCard` and is tightly coupled with that
@@ -50,7 +50,9 @@ type Props = {
   showPinButton?: boolean
 }
 
-export function Default(props: Props) {
+export function Default(
+  props: Props & Omit<LinkProps, 'to' | 'label' | 'children'>,
+) {
   const {view, showPinButton} = props
   const moderationOpts = useModerationOpts()
   const moderation = moderationOpts
diff --git a/src/components/Post/Embed/FeedEmbed.tsx b/src/components/Post/Embed/FeedEmbed.tsx
index fad4cd4d8..47d59e346 100644
--- a/src/components/Post/Embed/FeedEmbed.tsx
+++ b/src/components/Post/Embed/FeedEmbed.tsx
@@ -1,10 +1,9 @@
-import React from 'react'
-import {StyleSheet} from 'react-native'
+import {useMemo} from 'react'
 import {moderateFeedGenerator} from '@atproto/api'
 
-import {usePalette} from '#/lib/hooks/usePalette'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard'
+import {atoms as a, useTheme} from '#/alf'
+import * as FeedCard from '#/components/FeedCard'
 import {ContentHider} from '#/components/moderation/ContentHider'
 import {type EmbedType} from '#/types/bsky/post'
 import {type CommonProps} from './types'
@@ -14,13 +13,22 @@ export function FeedEmbed({
 }: CommonProps & {
   embed: EmbedType<'feed'>
 }) {
-  const pal = usePalette('default')
+  const t = useTheme()
   return (
-    <FeedSourceCard
-      feedUri={embed.view.uri}
-      style={[pal.view, pal.border, styles.customFeedOuter]}
-      showLikes
-    />
+    <FeedCard.Link
+      view={embed.view}
+      style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}>
+      <FeedCard.Outer>
+        <FeedCard.Header>
+          <FeedCard.Avatar src={embed.view.avatar} />
+          <FeedCard.TitleAndByline
+            title={embed.view.displayName}
+            creator={embed.view.creator}
+          />
+        </FeedCard.Header>
+        <FeedCard.Likes count={embed.view.likeCount || 0} />
+      </FeedCard.Outer>
+    </FeedCard.Link>
   )
 }
 
@@ -30,23 +38,16 @@ export function ModeratedFeedEmbed({
   embed: EmbedType<'feed'>
 }) {
   const moderationOpts = useModerationOpts()
-  const moderation = React.useMemo(() => {
+  const moderation = useMemo(() => {
     return moderationOpts
       ? moderateFeedGenerator(embed.view, moderationOpts)
       : undefined
   }, [embed.view, moderationOpts])
   return (
-    <ContentHider modui={moderation?.ui('contentList')}>
+    <ContentHider
+      modui={moderation?.ui('contentList')}
+      childContainerStyle={[a.pt_xs]}>
       <FeedEmbed embed={embed} />
     </ContentHider>
   )
 }
-
-const styles = StyleSheet.create({
-  customFeedOuter: {
-    borderWidth: StyleSheet.hairlineWidth,
-    borderRadius: 8,
-    paddingHorizontal: 12,
-    paddingVertical: 12,
-  },
-})
diff --git a/src/components/Post/Embed/ListEmbed.tsx b/src/components/Post/Embed/ListEmbed.tsx
index 82685d271..c1450bdcf 100644
--- a/src/components/Post/Embed/ListEmbed.tsx
+++ b/src/components/Post/Embed/ListEmbed.tsx
@@ -1,5 +1,4 @@
-import React from 'react'
-import {View} from 'react-native'
+import {useMemo} from 'react'
 import {moderateUserList} from '@atproto/api'
 
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -16,10 +15,10 @@ export function ListEmbed({
 }) {
   const t = useTheme()
   return (
-    <View
-      style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}>
-      <ListCard.Default view={embed.view} />
-    </View>
+    <ListCard.Default
+      view={embed.view}
+      style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}
+    />
   )
 }
 
@@ -29,13 +28,15 @@ export function ModeratedListEmbed({
   embed: EmbedType<'list'>
 }) {
   const moderationOpts = useModerationOpts()
-  const moderation = React.useMemo(() => {
+  const moderation = useMemo(() => {
     return moderationOpts
       ? moderateUserList(embed.view, moderationOpts)
       : undefined
   }, [embed.view, moderationOpts])
   return (
-    <ContentHider modui={moderation?.ui('contentList')}>
+    <ContentHider
+      modui={moderation?.ui('contentList')}
+      childContainerStyle={[a.pt_xs]}>
       <ListEmbed embed={embed} />
     </ContentHider>
   )
diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx
index ace85dc98..9c5444b27 100644
--- a/src/components/Post/Embed/index.tsx
+++ b/src/components/Post/Embed/index.tsx
@@ -268,64 +268,60 @@ export function QuoteEmbed({
   const [hover, setHover] = React.useState(false)
   return (
     <View
-      onPointerEnter={() => {
-        setHover(true)
-      }}
-      onPointerLeave={() => {
-        setHover(false)
-      }}>
+      style={[a.mt_sm]}
+      onPointerEnter={() => setHover(true)}
+      onPointerLeave={() => setHover(false)}>
       <ContentHider
         modui={moderation?.ui('contentList')}
-        style={[
-          a.rounded_md,
-          a.p_md,
-          a.mt_sm,
-          a.border,
-          t.atoms.border_contrast_low,
-          style,
-        ]}
+        style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
+        activeStyle={[a.p_md, a.pt_sm]}
         childContainerStyle={[a.pt_sm]}>
-        <SubtleWebHover hover={hover} />
-        <Link
-          hoverStyle={{borderColor: pal.colors.borderLinkHover}}
-          href={itemHref}
-          title={itemTitle}
-          onBeforePress={onBeforePress}>
-          <View pointerEvents="none">
-            <PostMeta
-              author={quote.author}
-              moderation={moderation}
-              showAvatar
-              postHref={itemHref}
-              timestamp={quote.indexedAt}
-            />
-          </View>
-          {moderation ? (
-            <PostAlerts
-              modui={moderation.ui('contentView')}
-              style={[a.py_xs]}
-            />
-          ) : null}
-          {richText ? (
-            <RichText
-              value={richText}
-              style={a.text_md}
-              numberOfLines={20}
-              disableLinks
-            />
-          ) : null}
-          {quote.embed && (
-            <Embed
-              embed={quote.embed}
-              moderation={moderation}
-              isWithinQuote={parentIsWithinQuote ?? true}
-              // already within quote? override nested
-              allowNestedQuotes={
-                parentIsWithinQuote ? false : parentAllowNestedQuotes
-              }
-            />
-          )}
-        </Link>
+        {({active}) => (
+          <>
+            {!active && <SubtleWebHover hover={hover} style={[a.rounded_md]} />}
+            <Link
+              style={[!active && a.p_md]}
+              hoverStyle={{borderColor: pal.colors.borderLinkHover}}
+              href={itemHref}
+              title={itemTitle}
+              onBeforePress={onBeforePress}>
+              <View pointerEvents="none">
+                <PostMeta
+                  author={quote.author}
+                  moderation={moderation}
+                  showAvatar
+                  postHref={itemHref}
+                  timestamp={quote.indexedAt}
+                />
+              </View>
+              {moderation ? (
+                <PostAlerts
+                  modui={moderation.ui('contentView')}
+                  style={[a.py_xs]}
+                />
+              ) : null}
+              {richText ? (
+                <RichText
+                  value={richText}
+                  style={a.text_md}
+                  numberOfLines={20}
+                  disableLinks
+                />
+              ) : null}
+              {quote.embed && (
+                <Embed
+                  embed={quote.embed}
+                  moderation={moderation}
+                  isWithinQuote={parentIsWithinQuote ?? true}
+                  // already within quote? override nested
+                  allowNestedQuotes={
+                    parentIsWithinQuote ? false : parentAllowNestedQuotes
+                  }
+                />
+              )}
+            </Link>
+          </>
+        )}
       </ContentHider>
     </View>
   )
diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx
index 9e94a413c..549a1b9f0 100644
--- a/src/components/moderation/ContentHider.tsx
+++ b/src/components/moderation/ContentHider.tsx
@@ -23,20 +23,23 @@ export function ContentHider({
   modui,
   ignoreMute,
   style,
+  activeStyle,
   childContainerStyle,
   children,
-}: React.PropsWithChildren<{
+}: {
   testID?: string
   modui: ModerationUI | undefined
   ignoreMute?: boolean
   style?: StyleProp<ViewStyle>
+  activeStyle?: StyleProp<ViewStyle>
   childContainerStyle?: StyleProp<ViewStyle>
-}>) {
+  children?: React.ReactNode | ((props: {active: boolean}) => React.ReactNode)
+}) {
   const blur = modui?.blurs[0]
   if (!blur || (ignoreMute && isJustAMute(modui))) {
     return (
       <View testID={testID} style={style}>
-        {children}
+        {typeof children === 'function' ? children({active: false}) : children}
       </View>
     )
   }
@@ -44,9 +47,9 @@ export function ContentHider({
     <ContentHiderActive
       testID={testID}
       modui={modui}
-      style={style}
+      style={[style, activeStyle]}
       childContainerStyle={childContainerStyle}>
-      {children}
+      {typeof children === 'function' ? children({active: true}) : children}
     </ContentHiderActive>
   )
 }
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>
+    )
+  }
+}
diff --git a/src/view/com/feeds/MissingFeed.tsx b/src/view/com/feeds/MissingFeed.tsx
new file mode 100644
index 000000000..3d281a731
--- /dev/null
+++ b/src/view/com/feeds/MissingFeed.tsx
@@ -0,0 +1,222 @@
+import {type StyleProp, View, type ViewStyle} from 'react-native'
+import {AtUri} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {cleanError} from '#/lib/strings/errors'
+import {isNative, isWeb} from '#/platform/detection'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {getFeedTypeFromUri} from '#/state/queries/feed'
+import {useProfileQuery} from '#/state/queries/profile'
+import {atoms as a, useTheme, web} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {Divider} from '#/components/Divider'
+import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
+import * as ProfileCard from '#/components/ProfileCard'
+import {Text} from '#/components/Typography'
+
+export function MissingFeed({
+  style,
+  hideTopBorder,
+  uri,
+  error,
+}: {
+  style?: StyleProp<ViewStyle>
+  hideTopBorder?: boolean
+  uri: string
+  error?: unknown
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const control = Dialog.useDialogControl()
+
+  const type = getFeedTypeFromUri(uri)
+
+  return (
+    <>
+      <Button
+        label={
+          type === 'feed'
+            ? _(msg`Could not connect to custom feed`)
+            : _(msg`Deleted list`)
+        }
+        accessibilityHint={_(msg`Tap for more information`)}
+        onPress={control.open}
+        style={[
+          a.flex_1,
+          a.p_lg,
+          a.gap_md,
+          !hideTopBorder && !a.border_t,
+          t.atoms.border_contrast_low,
+          a.justify_start,
+          style,
+        ]}>
+        <View style={[a.flex_row, a.align_center]}>
+          <View
+            style={[
+              {width: 36, height: 36},
+              t.atoms.bg_contrast_25,
+              a.rounded_sm,
+              a.mr_md,
+              a.align_center,
+              a.justify_center,
+            ]}>
+            <WarningIcon size="lg" />
+          </View>
+          <View style={[a.flex_1]}>
+            <Text
+              emoji
+              style={[a.text_sm, a.font_bold, a.leading_snug, a.italic]}
+              numberOfLines={1}>
+              {type === 'feed' ? (
+                <Trans>Feed unavailable</Trans>
+              ) : (
+                <Trans>Deleted list</Trans>
+              )}
+            </Text>
+            <Text
+              style={[
+                a.text_sm,
+                t.atoms.text_contrast_medium,
+                a.leading_snug,
+                a.italic,
+              ]}
+              numberOfLines={1}>
+              {isWeb ? (
+                <Trans>Click for information</Trans>
+              ) : (
+                <Trans>Tap for information</Trans>
+              )}
+            </Text>
+          </View>
+        </View>
+      </Button>
+
+      <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
+        <Dialog.Handle />
+        <DialogInner uri={uri} type={type} error={error} />
+      </Dialog.Outer>
+    </>
+  )
+}
+
+function DialogInner({
+  uri,
+  type,
+  error,
+}: {
+  uri: string
+  type: 'feed' | 'list'
+  error: unknown
+}) {
+  const control = Dialog.useDialogContext()
+  const t = useTheme()
+  const {_} = useLingui()
+  const atUri = new AtUri(uri)
+  const {data: profile, isError: isProfileError} = useProfileQuery({
+    did: atUri.host,
+  })
+  const moderationOpts = useModerationOpts()
+
+  return (
+    <Dialog.ScrollableInner
+      label={
+        type === 'feed'
+          ? _(msg`Unavailable feed information`)
+          : _(msg`Deleted list`)
+      }
+      style={web({maxWidth: 500})}>
+      <View style={[a.gap_sm]}>
+        <Text style={[a.font_heavy, a.text_2xl]}>
+          {type === 'feed' ? (
+            <Trans>Could not connect to feed service</Trans>
+          ) : (
+            <Trans>Deleted list</Trans>
+          )}
+        </Text>
+        <Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
+          {type === 'feed' ? (
+            <Trans>
+              We could not connect to the service that provides this custom
+              feed. It may be temporarily unavailable and experiencing issues,
+              or permanently unavailable.
+            </Trans>
+          ) : (
+            <Trans>We could not find this list. It was probably deleted.</Trans>
+          )}
+        </Text>
+        <Divider style={[a.my_md]} />
+        <Text style={[a.font_bold, t.atoms.text_contrast_high]}>
+          {type === 'feed' ? (
+            <Trans>Feed creator</Trans>
+          ) : (
+            <Trans>List creator</Trans>
+          )}
+        </Text>
+        {profile && moderationOpts && (
+          <View style={[a.w_full, a.align_start]}>
+            <ProfileCard.Link profile={profile} onPress={() => control.close()}>
+              <ProfileCard.Header>
+                <ProfileCard.Avatar
+                  profile={profile}
+                  moderationOpts={moderationOpts}
+                  disabledPreview
+                />
+                <ProfileCard.NameAndHandle
+                  profile={profile}
+                  moderationOpts={moderationOpts}
+                />
+              </ProfileCard.Header>
+            </ProfileCard.Link>
+          </View>
+        )}
+        {isProfileError && (
+          <Text
+            style={[
+              t.atoms.text_contrast_high,
+              a.italic,
+              a.text_center,
+              a.w_full,
+            ]}>
+            <Trans>Could not find profile</Trans>
+          </Text>
+        )}
+        {type === 'feed' && (
+          <>
+            <Text style={[a.font_bold, t.atoms.text_contrast_high, a.mt_md]}>
+              <Trans>Feed identifier</Trans>
+            </Text>
+            <Text style={[a.text_md, t.atoms.text_contrast_high, a.italic]}>
+              {atUri.rkey}
+            </Text>
+          </>
+        )}
+        {error instanceof Error && (
+          <>
+            <Text style={[a.font_bold, t.atoms.text_contrast_high, a.mt_md]}>
+              <Trans>Error message</Trans>
+            </Text>
+            <Text style={[a.text_md, t.atoms.text_contrast_high, a.italic]}>
+              {cleanError(error.message)}
+            </Text>
+          </>
+        )}
+      </View>
+      {isNative && (
+        <Button
+          label={_(msg`Close`)}
+          onPress={() => control.close()}
+          size="small"
+          variant="solid"
+          color="secondary"
+          style={[a.mt_5xl]}>
+          <ButtonText>
+            <Trans>Close</Trans>
+          </ButtonText>
+        </Button>
+      )}
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx
index 89e2d20e7..4de21e598 100644
--- a/src/view/com/notifications/NotificationFeedItem.tsx
+++ b/src/view/com/notifications/NotificationFeedItem.tsx
@@ -671,10 +671,12 @@ let NotificationFeedItem = ({
             {item.type === 'feedgen-like' && item.subjectUri ? (
               <FeedSourceCard
                 feedUri={item.subjectUri}
+                link={false}
                 style={[
                   t.atoms.bg,
                   t.atoms.border_contrast_low,
                   a.border,
+                  a.p_md,
                   styles.feedcard,
                 ]}
                 showLikes
@@ -1000,7 +1002,6 @@ const styles = StyleSheet.create({
   },
   feedcard: {
     borderRadius: 8,
-    paddingVertical: 12,
     marginTop: 6,
   },
   addedContainer: {
diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx
index eee642df3..1b454598b 100644
--- a/src/view/com/util/LoadingPlaceholder.tsx
+++ b/src/view/com/util/LoadingPlaceholder.tsx
@@ -1,10 +1,10 @@
 import {useMemo} from 'react'
 import {
-  DimensionValue,
-  StyleProp,
+  type DimensionValue,
+  type StyleProp,
   StyleSheet,
   View,
-  ViewStyle,
+  type ViewStyle,
 } from 'react-native'
 
 import {usePalette} from '#/lib/hooks/usePalette'
@@ -233,8 +233,7 @@ export function FeedLoadingPlaceholder({
     <View
       style={[
         {
-          paddingHorizontal: 12,
-          paddingVertical: 18,
+          padding: 16,
           borderTopWidth: showTopBorder ? StyleSheet.hairlineWidth : 0,
         },
         pal.border,
@@ -244,7 +243,7 @@ export function FeedLoadingPlaceholder({
         <LoadingPlaceholder
           width={36}
           height={36}
-          style={[styles.avatar, {borderRadius: 6}]}
+          style={[styles.avatar, {borderRadius: 8}]}
         />
         <View style={[s.flex1]}>
           <LoadingPlaceholder width={100} height={8} style={[s.mt5, s.mb10]} />
@@ -252,12 +251,7 @@ export function FeedLoadingPlaceholder({
         </View>
       </View>
       {showLowerPlaceholder && (
-        <View style={{paddingHorizontal: 5, marginTop: 10}}>
-          <LoadingPlaceholder
-            width={260}
-            height={8}
-            style={{marginVertical: 12}}
-          />
+        <View style={{marginTop: 12}}>
           <LoadingPlaceholder width={120} height={8} />
         </View>
       )}
@@ -352,8 +346,7 @@ const styles = StyleSheet.create({
   },
   avatar: {
     borderRadius: 999,
-    marginRight: 10,
-    marginLeft: 8,
+    marginRight: 12,
   },
   notification: {
     flexDirection: 'row',
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index 9ae3dbd8d..b244c7558 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -36,6 +36,7 @@ import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/compon
 import {FloppyDisk_Stroke2_Corner0_Rounded as SaveIcon} from '#/components/icons/FloppyDisk'
 import * as Layout from '#/components/Layout'
 import {Loader} from '#/components/Loader'
+import {Text as NewText} from '#/components/Typography'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
 export function SavedFeeds({}: Props) {
@@ -296,7 +297,7 @@ function ListItem({
         <FeedSourceCard
           key={feedUri}
           feedUri={feedUri}
-          style={[isPinned && {paddingRight: 8}]}
+          style={[isPinned && a.pr_sm]}
           showMinimalPlaceholder
           hideTopBorder={true}
         />
@@ -391,26 +392,17 @@ function ListItem({
 function FollowingFeedCard() {
   const t = useTheme()
   return (
-    <View
-      style={[
-        a.flex_row,
-        a.align_center,
-        a.flex_1,
-        {
-          paddingHorizontal: 18,
-          paddingVertical: 20,
-        },
-      ]}>
+    <View style={[a.flex_row, a.align_center, a.flex_1, a.p_lg]}>
       <View
         style={[
           a.align_center,
           a.justify_center,
           a.rounded_sm,
+          a.mr_md,
           {
             width: 36,
             height: 36,
             backgroundColor: t.palette.primary_500,
-            marginRight: 10,
           },
         ]}>
         <FilterTimeline
@@ -423,11 +415,10 @@ function FollowingFeedCard() {
           fill={t.palette.white}
         />
       </View>
-      <View
-        style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}>
-        <Text type="lg-medium" style={[t.atoms.text]} numberOfLines={1}>
+      <View style={[a.flex_1, a.flex_row, a.gap_sm, a.align_center]}>
+        <NewText style={[a.text_sm, a.font_bold, a.leading_snug]}>
           <Trans>Following</Trans>
-        </Text>
+        </NewText>
       </View>
     </View>
   )