about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/LabelingServiceCard/index.tsx10
-rw-r--r--src/components/hooks/useHeaderOffset.ts16
-rw-r--r--src/components/icons/Home.tsx9
-rw-r--r--src/components/moderation/LabelsOnMe.tsx4
-rw-r--r--src/lib/constants.ts21
-rw-r--r--src/screens/Feeds/NoFollowingFeed.tsx50
-rw-r--r--src/screens/Feeds/NoSavedFeedsOfAnyType.tsx57
-rw-r--r--src/screens/Home/NoFeedsPinned.tsx129
-rw-r--r--src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx2
-rw-r--r--src/screens/Onboarding/StepFinished.tsx57
-rw-r--r--src/state/preferences/feed-tuners.tsx5
-rw-r--r--src/state/queries/feed.ts101
-rw-r--r--src/state/queries/post-feed.ts6
-rw-r--r--src/state/queries/preferences/const.ts17
-rw-r--r--src/state/queries/preferences/index.ts71
-rw-r--r--src/state/queries/preferences/types.ts5
-rw-r--r--src/state/session/agent.ts34
-rw-r--r--src/state/shell/selected-feed.tsx35
-rw-r--r--src/view/com/feeds/FeedPage.tsx21
-rw-r--r--src/view/com/feeds/FeedSourceCard.tsx74
-rw-r--r--src/view/com/home/HomeHeader.tsx19
-rw-r--r--src/view/com/lightbox/Lightbox.tsx25
-rw-r--r--src/view/com/modals/SelfLabel.tsx13
-rw-r--r--src/view/com/pager/TabBar.tsx16
-rw-r--r--src/view/com/posts/Feed.tsx15
-rw-r--r--src/view/com/posts/FeedErrorMessage.tsx53
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.tsx13
-rw-r--r--src/view/screens/Feeds.tsx164
-rw-r--r--src/view/screens/Home.tsx99
-rw-r--r--src/view/screens/PreferencesFollowingFeed.tsx21
-rw-r--r--src/view/screens/ProfileFeed.tsx111
-rw-r--r--src/view/screens/ProfileList.tsx109
-rw-r--r--src/view/screens/SavedFeeds.tsx261
-rw-r--r--src/view/shell/desktop/Feeds.tsx28
34 files changed, 1126 insertions, 545 deletions
diff --git a/src/components/LabelingServiceCard/index.tsx b/src/components/LabelingServiceCard/index.tsx
index 2bb7ed59c..542f2d299 100644
--- a/src/components/LabelingServiceCard/index.tsx
+++ b/src/components/LabelingServiceCard/index.tsx
@@ -1,18 +1,18 @@
 import React from 'react'
 import {View} from 'react-native'
+import {AppBskyLabelerDefs} from '@atproto/api'
 import {msg, Plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {AppBskyLabelerDefs} from '@atproto/api'
 
 import {getLabelingServiceTitle} from '#/lib/moderation'
-import {Link as InternalLink, LinkProps} from '#/components/Link'
-import {Text} from '#/components/Typography'
+import {sanitizeHandle} from '#/lib/strings/handles'
 import {useLabelerInfoQuery} from '#/state/queries/labeler'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
+import {Link as InternalLink, LinkProps} from '#/components/Link'
 import {RichText} from '#/components/RichText'
+import {Text} from '#/components/Typography'
 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
-import {UserAvatar} from '#/view/com/util/UserAvatar'
-import {sanitizeHandle} from '#/lib/strings/handles'
 
 type LabelingServiceProps = {
   labeler: AppBskyLabelerDefs.LabelerViewDetailed
diff --git a/src/components/hooks/useHeaderOffset.ts b/src/components/hooks/useHeaderOffset.ts
new file mode 100644
index 000000000..e2290c04f
--- /dev/null
+++ b/src/components/hooks/useHeaderOffset.ts
@@ -0,0 +1,16 @@
+import {useWindowDimensions} from 'react-native'
+
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+
+export function useHeaderOffset() {
+  const {isDesktop, isTablet} = useWebMediaQueries()
+  const {fontScale} = useWindowDimensions()
+  if (isDesktop || isTablet) {
+    return 0
+  }
+  const navBarHeight = 42
+  const tabBarPad = 10 + 10 + 3 // padding + border
+  const normalLineHeight = 1.2
+  const tabBarText = 16 * normalLineHeight * fontScale
+  return navBarHeight + tabBarPad + tabBarText
+}
diff --git a/src/components/icons/Home.tsx b/src/components/icons/Home.tsx
new file mode 100644
index 000000000..e150b7b81
--- /dev/null
+++ b/src/components/icons/Home.tsx
@@ -0,0 +1,9 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Home_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M11.46 1.362a2 2 0 0 1 1.08 0c.249.07.448.188.611.301.146.102.306.232.467.363l6.421 5.218.046.036c.169.137.38.308.54.53a2 2 0 0 1 .304.64c.073.264.072.536.071.753v9.229c0 .252 0 .498-.017.706a2.023 2.023 0 0 1-.201.77 2 2 0 0 1-.874.874 2.02 2.02 0 0 1-.77.201c-.208.017-.454.017-.706.017H5.568c-.252 0-.498 0-.706-.017a2.02 2.02 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C3 18.93 3 18.684 3 18.432V9.203c0-.217-.002-.49.07-.754a2 2 0 0 1 .304-.638c.16-.223.372-.394.541-.53l.045-.037 6.422-5.218c.161-.13.321-.26.467-.362.163-.114.362-.232.612-.302Zm.532 1.943c-.077.054-.18.136-.37.29l-6.4 5.2a6.315 6.315 0 0 0-.215.18c-.002 0-.003.002-.004.003v.004C5 9.036 5 9.112 5 9.262V18.4a8.18 8.18 0 0 0 .011.588l.014.002c.116.01.278.01.575.01H8v-5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v5h2.4a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V9.262c0-.15 0-.226-.003-.28v-.004l-.003-.003a6.448 6.448 0 0 0-.216-.18l-6.4-5.2a7.373 7.373 0 0 0-.37-.29L12 3.299l-.008.006ZM14 19v-5h-4v5h4Z',
+})
+
+export const Home_Filled_Corner0_Rounded = createSinglePathSVG({
+  path: 'M13.261 1.736a2 2 0 0 0-2.522 0l-7 5.687A2 2 0 0 0 3 8.976V19a2 2 0 0 0 2 2h3v-8a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v8h3a2 2 0 0 0 2-2V8.976a2 2 0 0 0-.739-1.553l-7-5.687ZM14 21h-4v-7h4v7Z',
+})
diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx
index 46825d761..ea5c74f9e 100644
--- a/src/components/moderation/LabelsOnMe.tsx
+++ b/src/components/moderation/LabelsOnMe.tsx
@@ -3,10 +3,10 @@ import {StyleProp, View, ViewStyle} from 'react-native'
 import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api'
 import {msg, Plural} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useSession} from '#/state/session'
 
+import {useSession} from '#/state/session'
 import {atoms as a} from '#/alf'
-import {Button, ButtonText, ButtonIcon, ButtonSize} from '#/components/Button'
+import {Button, ButtonIcon, ButtonSize, ButtonText} from '#/components/Button'
 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 import {
   LabelsOnMeDialog,
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index d7bec1e18..83f513911 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -1,4 +1,5 @@
 import {Insets, Platform} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
 
 export const LOCAL_DEV_SERVICE =
   Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
@@ -44,7 +45,7 @@ export function IS_TEST_USER(handle?: string) {
 }
 
 export function IS_PROD_SERVICE(url?: string) {
-  return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
+  return url && url !== STAGING_SERVICE && !url.startsWith(LOCAL_DEV_SERVICE)
 }
 
 export const PROD_DEFAULT_FEED = (rkey: string) =>
@@ -92,6 +93,24 @@ export const BSKY_FEED_OWNER_DIDS = [
   'did:plc:q6gjnaw2blty4crticxkmujt',
 ]
 
+export const DISCOVER_FEED_URI =
+  'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'
+export const DISCOVER_SAVED_FEED = {
+  type: 'feed',
+  value: DISCOVER_FEED_URI,
+  pinned: true,
+}
+export const TIMELINE_SAVED_FEED = {
+  type: 'timeline',
+  value: 'following',
+  pinned: true,
+}
+
+export const RECOMMENDED_SAVED_FEEDS: Pick<
+  AppBskyActorDefs.SavedFeed,
+  'type' | 'value' | 'pinned'
+>[] = [DISCOVER_SAVED_FEED, TIMELINE_SAVED_FEED]
+
 export const GIF_SERVICE = 'https://gifs.bsky.app'
 
 export const GIF_SEARCH = (params: string) =>
diff --git a/src/screens/Feeds/NoFollowingFeed.tsx b/src/screens/Feeds/NoFollowingFeed.tsx
new file mode 100644
index 000000000..03ced8ebd
--- /dev/null
+++ b/src/screens/Feeds/NoFollowingFeed.tsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {TIMELINE_SAVED_FEED} from '#/lib/constants'
+import {useAddSavedFeedsMutation} from '#/state/queries/preferences'
+import {atoms as a, useTheme} from '#/alf'
+import {InlineLinkText} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function NoFollowingFeed() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {mutateAsync: addSavedFeeds} = useAddSavedFeedsMutation()
+
+  const addRecommendedFeeds = React.useCallback(
+    (e: any) => {
+      e.preventDefault()
+
+      addSavedFeeds([
+        {
+          ...TIMELINE_SAVED_FEED,
+          pinned: true,
+        },
+      ])
+
+      // prevent navigation
+      return false
+    },
+    [addSavedFeeds],
+  )
+
+  return (
+    <View style={[a.flex_row, a.flex_wrap, a.align_center, a.py_md, a.px_lg]}>
+      <Text
+        style={[a.leading_snug, t.atoms.text_contrast_medium, {maxWidth: 310}]}>
+        <Trans>Looks like you're missing a following feed.</Trans>{' '}
+      </Text>
+
+      <InlineLinkText
+        to="/"
+        label={_(msg`Add the default feed of only people you follow`)}
+        onPress={addRecommendedFeeds}
+        style={[a.leading_snug]}>
+        <Trans>Click here to add one.</Trans>
+      </InlineLinkText>
+    </View>
+  )
+}
diff --git a/src/screens/Feeds/NoSavedFeedsOfAnyType.tsx b/src/screens/Feeds/NoSavedFeedsOfAnyType.tsx
new file mode 100644
index 000000000..8f6bd9d2e
--- /dev/null
+++ b/src/screens/Feeds/NoSavedFeedsOfAnyType.tsx
@@ -0,0 +1,57 @@
+import React from 'react'
+import {View} from 'react-native'
+import {TID} from '@atproto/common-web'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {RECOMMENDED_SAVED_FEEDS} from '#/lib/constants'
+import {useOverwriteSavedFeedsMutation} from '#/state/queries/preferences'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {Text} from '#/components/Typography'
+
+/**
+ * Explicitly named, since the CTA in this component will overwrite all saved
+ * feeds if pressed. It should only be presented to the user if they actually
+ * have no other feeds saved.
+ */
+export function NoSavedFeedsOfAnyType() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {isPending, mutateAsync: overwriteSavedFeeds} =
+    useOverwriteSavedFeedsMutation()
+
+  const addRecommendedFeeds = React.useCallback(async () => {
+    await overwriteSavedFeeds(
+      RECOMMENDED_SAVED_FEEDS.map(f => ({
+        ...f,
+        id: TID.nextStr(),
+      })),
+    )
+  }, [overwriteSavedFeeds])
+
+  return (
+    <View
+      style={[a.flex_row, a.flex_wrap, a.justify_between, a.p_xl, a.gap_md]}>
+      <Text
+        style={[a.leading_snug, t.atoms.text_contrast_medium, {maxWidth: 310}]}>
+        <Trans>
+          Looks like you haven't saved any feeds! Use our recommendations or
+          browse more below.
+        </Trans>
+      </Text>
+
+      <Button
+        disabled={isPending}
+        label={_(msg`Apply default recommended feeds`)}
+        size="small"
+        variant="solid"
+        color="primary"
+        onPress={addRecommendedFeeds}>
+        <ButtonIcon icon={Plus} position="left" />
+        <ButtonText>{_(msg`Use recommended`)}</ButtonText>
+      </Button>
+    </View>
+  )
+}
diff --git a/src/screens/Home/NoFeedsPinned.tsx b/src/screens/Home/NoFeedsPinned.tsx
new file mode 100644
index 000000000..e804e3e09
--- /dev/null
+++ b/src/screens/Home/NoFeedsPinned.tsx
@@ -0,0 +1,129 @@
+import React from 'react'
+import {View} from 'react-native'
+import {TID} from '@atproto/common-web'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {DISCOVER_SAVED_FEED, TIMELINE_SAVED_FEED} from '#/lib/constants'
+import {isNative} from '#/platform/detection'
+import {useOverwriteSavedFeedsMutation} from '#/state/queries/preferences'
+import {UsePreferencesQueryResponse} from '#/state/queries/preferences'
+import {NavigationProp} from 'lib/routes/types'
+import {CenteredView} from '#/view/com/util/Views'
+import {atoms as a} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
+import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function NoFeedsPinned({
+  preferences,
+}: {
+  preferences: UsePreferencesQueryResponse
+}) {
+  const {_} = useLingui()
+  const headerOffset = useHeaderOffset()
+  const navigation = useNavigation<NavigationProp>()
+  const {isPending, mutateAsync: overwriteSavedFeeds} =
+    useOverwriteSavedFeedsMutation()
+
+  const addRecommendedFeeds = React.useCallback(async () => {
+    let skippedTimeline = false
+    let skippedDiscover = false
+    let remainingSavedFeeds = []
+
+    // remove first instance of both timeline and discover, since we're going to overwrite them
+    for (const savedFeed of preferences.savedFeeds) {
+      if (savedFeed.type === 'timeline' && !skippedTimeline) {
+        skippedTimeline = true
+      } else if (
+        savedFeed.value === DISCOVER_SAVED_FEED.value &&
+        !skippedDiscover
+      ) {
+        skippedDiscover = true
+      } else {
+        remainingSavedFeeds.push(savedFeed)
+      }
+    }
+
+    const toSave = [
+      {
+        ...DISCOVER_SAVED_FEED,
+        pinned: true,
+        id: TID.nextStr(),
+      },
+      {
+        ...TIMELINE_SAVED_FEED,
+        pinned: true,
+        id: TID.nextStr(),
+      },
+      ...remainingSavedFeeds,
+    ]
+
+    await overwriteSavedFeeds(toSave)
+  }, [overwriteSavedFeeds, preferences.savedFeeds])
+
+  const onPressFeedsLink = React.useCallback(() => {
+    if (isNative) {
+      // Hack that's necessary due to how our navigators are set up.
+      navigation.navigate('FeedsTab')
+      navigation.popToTop()
+      return false
+    }
+  }, [navigation])
+
+  return (
+    <CenteredView sideBorders style={[a.h_full_vh]}>
+      <View
+        style={[
+          a.align_center,
+          a.h_full_vh,
+          a.py_3xl,
+          a.px_xl,
+          {
+            paddingTop: headerOffset + a.py_3xl.paddingTop,
+          },
+        ]}>
+        <View style={[a.align_center, a.gap_sm, a.pb_xl]}>
+          <Text style={[a.text_xl, a.font_bold]}>
+            <Trans>Whoops!</Trans>
+          </Text>
+          <Text
+            style={[a.text_md, a.text_center, a.leading_snug, {maxWidth: 340}]}>
+            <Trans>
+              Looks like you unpinned all your feeds. But don't worry, you can
+              add some below 😄
+            </Trans>
+          </Text>
+        </View>
+
+        <View style={[a.flex_row, a.gap_md, a.justify_center, a.flex_wrap]}>
+          <Button
+            disabled={isPending}
+            label={_(msg`Apply default recommended feeds`)}
+            size="medium"
+            variant="solid"
+            color="primary"
+            onPress={addRecommendedFeeds}>
+            <ButtonIcon icon={Plus} position="left" />
+            <ButtonText>{_(msg`Add recommended feeds`)}</ButtonText>
+          </Button>
+
+          <Link
+            label={_(msg`Browse other feeds`)}
+            to="/feeds"
+            onPress={onPressFeedsLink}
+            size="medium"
+            variant="solid"
+            color="secondary">
+            <ButtonIcon icon={ListSparkle} position="left" />
+            <ButtonText>{_(msg`Browse other feeds`)}</ButtonText>
+          </Link>
+        </View>
+      </View>
+    </CenteredView>
+  )
+}
diff --git a/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx b/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
index d2b2a5f39..0aa063faa 100644
--- a/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
+++ b/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import {View} from 'react-native'
 import {Image} from 'expo-image'
 import {LinearGradient} from 'expo-linear-gradient'
-import {Trans, msg} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx
index e7054fb1f..4cc611ef4 100644
--- a/src/screens/Onboarding/StepFinished.tsx
+++ b/src/screens/Onboarding/StepFinished.tsx
@@ -1,13 +1,15 @@
 import React from 'react'
 import {View} from 'react-native'
+import {TID} from '@atproto/common-web'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {useAnalytics} from '#/lib/analytics/analytics'
-import {BSKY_APP_ACCOUNT_DID} from '#/lib/constants'
+import {BSKY_APP_ACCOUNT_DID, IS_PROD_SERVICE} from '#/lib/constants'
+import {DISCOVER_SAVED_FEED, TIMELINE_SAVED_FEED} from '#/lib/constants'
 import {logEvent} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
-import {useSetSaveFeedsMutation} from '#/state/queries/preferences'
+import {useOverwriteSavedFeedsMutation} from '#/state/queries/preferences'
 import {useAgent} from '#/state/session'
 import {useOnboardingDispatch} from '#/state/shell'
 import {
@@ -37,7 +39,7 @@ export function StepFinished() {
   const {state, dispatch} = React.useContext(Context)
   const onboardDispatch = useOnboardingDispatch()
   const [saving, setSaving] = React.useState(false)
-  const {mutateAsync: saveFeeds} = useSetSaveFeedsMutation()
+  const {mutateAsync: overwriteSavedFeeds} = useOverwriteSavedFeedsMutation()
   const {getAgent} = useAgent()
 
   const finishOnboarding = React.useCallback(async () => {
@@ -64,10 +66,41 @@ export function StepFinished() {
         // these must be serial
         (async () => {
           await getAgent().setInterestsPref({tags: selectedInterests})
-          await saveFeeds({
-            saved: selectedFeeds,
-            pinned: selectedFeeds,
-          })
+
+          // TODO: In the reduced onboarding, we'll want to exit early here.
+
+          const otherFeeds = selectedFeeds.length
+            ? selectedFeeds.map(f => ({
+                type: 'feed',
+                value: f,
+                pinned: true,
+                id: TID.nextStr(),
+              }))
+            : []
+
+          /*
+           * If no selected feeds and we're in prod, add the discover feed
+           * (mimics old behavior)
+           */
+          if (
+            IS_PROD_SERVICE(getAgent().service.toString()) &&
+            !otherFeeds.length
+          ) {
+            otherFeeds.push({
+              ...DISCOVER_SAVED_FEED,
+              pinned: true,
+              id: TID.nextStr(),
+            })
+          }
+
+          await overwriteSavedFeeds([
+            {
+              ...TIMELINE_SAVED_FEED,
+              pinned: true,
+              id: TID.nextStr(),
+            },
+            ...otherFeeds,
+          ])
         })(),
       ])
     } catch (e: any) {
@@ -82,7 +115,15 @@ export function StepFinished() {
     track('OnboardingV2:StepFinished:End')
     track('OnboardingV2:Complete')
     logEvent('onboarding:finished:nextPressed', {})
-  }, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track, getAgent])
+  }, [
+    state,
+    dispatch,
+    onboardDispatch,
+    setSaving,
+    overwriteSavedFeeds,
+    track,
+    getAgent,
+  ])
 
   React.useEffect(() => {
     track('OnboardingV2:StepFinished:Start')
diff --git a/src/state/preferences/feed-tuners.tsx b/src/state/preferences/feed-tuners.tsx
index c4954d20a..ac129d172 100644
--- a/src/state/preferences/feed-tuners.tsx
+++ b/src/state/preferences/feed-tuners.tsx
@@ -1,9 +1,10 @@
 import {useMemo} from 'react'
+
 import {FeedTuner} from '#/lib/api/feed-manip'
 import {FeedDescriptor} from '../queries/post-feed'
-import {useLanguagePrefs} from './languages'
 import {usePreferencesQuery} from '../queries/preferences'
 import {useSession} from '../session'
+import {useLanguagePrefs} from './languages'
 
 export function useFeedTuners(feedDesc: FeedDescriptor) {
   const langPrefs = useLanguagePrefs()
@@ -20,7 +21,7 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
     if (feedDesc.startsWith('list')) {
       return [FeedTuner.dedupReposts]
     }
-    if (feedDesc === 'home' || feedDesc === 'following') {
+    if (feedDesc === 'following') {
       const feedTuners = []
 
       if (preferences?.feedViewPrefs.hideReposts) {
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index 1741d113c..19cded087 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -1,4 +1,5 @@
 import {
+  AppBskyActorDefs,
   AppBskyFeedDefs,
   AppBskyGraphDefs,
   AppBskyUnspeccedGetPopularFeedGenerators,
@@ -13,16 +14,19 @@ import {
   useQuery,
 } from '@tanstack/react-query'
 
+import {DISCOVER_FEED_URI, DISCOVER_SAVED_FEED} from '#/lib/constants'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {STALE} from '#/state/queries'
 import {usePreferencesQuery} from '#/state/queries/preferences'
 import {useAgent, useSession} from '#/state/session'
 import {router} from '#/routes'
+import {FeedDescriptor} from './post-feed'
 
 export type FeedSourceFeedInfo = {
   type: 'feed'
   uri: string
+  feedDescriptor: FeedDescriptor
   route: {
     href: string
     name: string
@@ -41,6 +45,7 @@ export type FeedSourceFeedInfo = {
 export type FeedSourceListInfo = {
   type: 'list'
   uri: string
+  feedDescriptor: FeedDescriptor
   route: {
     href: string
     name: string
@@ -79,6 +84,7 @@ export function hydrateFeedGenerator(
   return {
     type: 'feed',
     uri: view.uri,
+    feedDescriptor: `feedgen|${view.uri}`,
     cid: view.cid,
     route: {
       href,
@@ -110,6 +116,7 @@ export function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo {
   return {
     type: 'list',
     uri: view.uri,
+    feedDescriptor: `list|${view.uri}`,
     route: {
       href,
       name: route[0],
@@ -202,27 +209,15 @@ export function useSearchPopularFeedsMutation() {
   })
 }
 
-const FOLLOWING_FEED_STUB: FeedSourceInfo = {
-  type: 'feed',
-  displayName: 'Following',
-  uri: '',
-  route: {
-    href: '/',
-    name: 'Home',
-    params: {},
-  },
-  cid: '',
-  avatar: '',
-  description: new RichText({text: ''}),
-  creatorDid: '',
-  creatorHandle: '',
-  likeCount: 0,
-  likeUri: '',
+export type SavedFeedSourceInfo = FeedSourceInfo & {
+  savedFeed: AppBskyActorDefs.SavedFeed
 }
-const DISCOVER_FEED_STUB: FeedSourceInfo = {
+
+const PWI_DISCOVER_FEED_STUB: SavedFeedSourceInfo = {
   type: 'feed',
   displayName: 'Discover',
-  uri: '',
+  uri: DISCOVER_FEED_URI,
+  feedDescriptor: `feedgen|${DISCOVER_FEED_URI}`,
   route: {
     href: '/',
     name: 'Home',
@@ -235,6 +230,11 @@ const DISCOVER_FEED_STUB: FeedSourceInfo = {
   creatorHandle: '',
   likeCount: 0,
   likeUri: '',
+  // ---
+  savedFeed: {
+    id: 'pwi-discover',
+    ...DISCOVER_SAVED_FEED,
+  },
 }
 
 const pinnedFeedInfosQueryKeyRoot = 'pinnedFeedsInfos'
@@ -243,43 +243,45 @@ export function usePinnedFeedsInfos() {
   const {hasSession} = useSession()
   const {getAgent} = useAgent()
   const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery()
-  const pinnedUris = preferences?.feeds?.pinned ?? []
+  const pinnedItems = preferences?.savedFeeds.filter(feed => feed.pinned) ?? []
 
   return useQuery({
     staleTime: STALE.INFINITY,
     enabled: !isLoadingPrefs,
     queryKey: [
       pinnedFeedInfosQueryKeyRoot,
-      (hasSession ? 'authed:' : 'unauthed:') + pinnedUris.join(','),
+      (hasSession ? 'authed:' : 'unauthed:') +
+        pinnedItems.map(f => f.value).join(','),
     ],
     queryFn: async () => {
-      let resolved = new Map()
+      if (!hasSession) {
+        return [PWI_DISCOVER_FEED_STUB]
+      }
+
+      let resolved = new Map<string, FeedSourceInfo>()
 
       // Get all feeds. We can do this in a batch.
-      const feedUris = pinnedUris.filter(
-        uri => getFeedTypeFromUri(uri) === 'feed',
-      )
+      const pinnedFeeds = pinnedItems.filter(feed => feed.type === 'feed')
       let feedsPromise = Promise.resolve()
-      if (feedUris.length > 0) {
+      if (pinnedFeeds.length > 0) {
         feedsPromise = getAgent()
           .app.bsky.feed.getFeedGenerators({
-            feeds: feedUris,
+            feeds: pinnedFeeds.map(f => f.value),
           })
           .then(res => {
-            for (let feedView of res.data.feeds) {
+            for (let i = 0; i < res.data.feeds.length; i++) {
+              const feedView = res.data.feeds[i]
               resolved.set(feedView.uri, hydrateFeedGenerator(feedView))
             }
           })
       }
 
       // Get all lists. This currently has to be done individually.
-      const listUris = pinnedUris.filter(
-        uri => getFeedTypeFromUri(uri) === 'list',
-      )
-      const listsPromises = listUris.map(listUri =>
+      const pinnedLists = pinnedItems.filter(feed => feed.type === 'list')
+      const listsPromises = pinnedLists.map(list =>
         getAgent()
           .app.bsky.graph.getList({
-            list: listUri,
+            list: list.value,
             limit: 1,
           })
           .then(res => {
@@ -288,12 +290,37 @@ export function usePinnedFeedsInfos() {
           }),
       )
 
-      // The returned result will have the original order.
-      const result = [hasSession ? FOLLOWING_FEED_STUB : DISCOVER_FEED_STUB]
       await Promise.allSettled([feedsPromise, ...listsPromises])
-      for (let pinnedUri of pinnedUris) {
-        if (resolved.has(pinnedUri)) {
-          result.push(resolved.get(pinnedUri))
+
+      // order the feeds/lists in the order they were pinned
+      const result: SavedFeedSourceInfo[] = []
+      for (let pinnedItem of pinnedItems) {
+        const feedInfo = resolved.get(pinnedItem.value)
+        if (feedInfo) {
+          result.push({
+            ...feedInfo,
+            savedFeed: pinnedItem,
+          })
+        } else if (pinnedItem.type === 'timeline') {
+          result.push({
+            type: 'feed',
+            displayName: 'Following',
+            uri: pinnedItem.value,
+            feedDescriptor: 'following',
+            route: {
+              href: '/',
+              name: 'Home',
+              params: {},
+            },
+            cid: '',
+            avatar: '',
+            description: new RichText({text: ''}),
+            creatorDid: '',
+            creatorHandle: '',
+            likeCount: 0,
+            likeUri: '',
+            savedFeed: pinnedItem,
+          })
         }
       }
       return result
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index dc86a9ba0..7b312edfe 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -44,8 +44,8 @@ type AuthorFilter =
   | 'posts_with_media'
 type FeedUri = string
 type ListUri = string
+
 export type FeedDescriptor =
-  | 'home'
   | 'following'
   | `author|${ActorDid}|${AuthorFilter}`
   | `feedgen|${FeedUri}`
@@ -390,7 +390,7 @@ function createApi({
   userInterests?: string
   getAgent: () => BskyAgent
 }) {
-  if (feedDesc === 'home') {
+  if (feedDesc === 'following') {
     if (feedParams.mergeFeedEnabled) {
       return new MergeFeedAPI({
         getAgent,
@@ -401,8 +401,6 @@ function createApi({
     } else {
       return new HomeFeedAPI({getAgent, userInterests})
     }
-  } else if (feedDesc === 'following') {
-    return new FollowingFeedAPI({getAgent})
   } else if (feedDesc.startsWith('author')) {
     const [_, actor, filter] = feedDesc.split('|')
     return new AuthorFeedAPI({getAgent, feedParams: {actor, filter}})
diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts
index 4cb4d1e96..d94edb47e 100644
--- a/src/state/queries/preferences/const.ts
+++ b/src/state/queries/preferences/const.ts
@@ -1,8 +1,8 @@
+import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
 import {
-  UsePreferencesQueryResponse,
   ThreadViewPreferences,
+  UsePreferencesQueryResponse,
 } from '#/state/queries/preferences/types'
-import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
 
 export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
   {
@@ -20,20 +20,8 @@ export const DEFAULT_THREAD_VIEW_PREFS: ThreadViewPreferences = {
   lab_treeViewEnabled: false,
 }
 
-const DEFAULT_PROD_FEED_PREFIX = (rkey: string) =>
-  `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}`
-export const DEFAULT_PROD_FEEDS = {
-  pinned: [DEFAULT_PROD_FEED_PREFIX('whats-hot')],
-  saved: [DEFAULT_PROD_FEED_PREFIX('whats-hot')],
-}
-
 export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
   birthDate: new Date('2022-11-17'), // TODO(pwi)
-  feeds: {
-    saved: [],
-    pinned: [],
-    unpinned: [],
-  },
   moderationPrefs: {
     adultContentEnabled: false,
     labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
@@ -45,4 +33,5 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
   threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
   userAge: 13, // TODO(pwi)
   interests: {tags: []},
+  savedFeeds: [],
 }
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
index f51eaac2a..b3d2fa9ec 100644
--- a/src/state/queries/preferences/index.ts
+++ b/src/state/queries/preferences/index.ts
@@ -51,14 +51,11 @@ export function usePreferencesQuery() {
 
         const preferences: UsePreferencesQueryResponse = {
           ...res,
-          feeds: {
-            saved: res.feeds?.saved || [],
-            pinned: res.feeds?.pinned || [],
-            unpinned:
-              res.feeds.saved?.filter(f => {
-                return !res.feeds.pinned?.includes(f)
-              }) || [],
-          },
+          savedFeeds: res.savedFeeds.filter(f => f.type !== 'unknown'),
+          /**
+           * Special preference, only used for following feed, previously
+           * called `home`
+           */
           feedViewPrefs: {
             ...DEFAULT_HOME_FEED_PREFS,
             ...(res.feedViewPrefs.home || {}),
@@ -168,6 +165,10 @@ export function useSetFeedViewPreferencesMutation() {
 
   return useMutation<void, unknown, Partial<BskyFeedViewPreference>>({
     mutationFn: async prefs => {
+      /*
+       * special handling here, merged into `feedViewPrefs` above, since
+       * following was previously called `home`
+       */
       await getAgent().setFeedViewPrefs('home', prefs)
       // triggers a refetch
       await queryClient.invalidateQueries({
@@ -192,17 +193,13 @@ export function useSetThreadViewPreferencesMutation() {
   })
 }
 
-export function useSetSaveFeedsMutation() {
+export function useOverwriteSavedFeedsMutation() {
   const queryClient = useQueryClient()
   const {getAgent} = useAgent()
 
-  return useMutation<
-    void,
-    unknown,
-    Pick<UsePreferencesQueryResponse['feeds'], 'saved' | 'pinned'>
-  >({
-    mutationFn: async ({saved, pinned}) => {
-      await getAgent().setSavedFeeds(saved, pinned)
+  return useMutation<void, unknown, AppBskyActorDefs.SavedFeed[]>({
+    mutationFn: async savedFeeds => {
+      await getAgent().overwriteSavedFeeds(savedFeeds)
       // triggers a refetch
       await queryClient.invalidateQueries({
         queryKey: preferencesQueryKey,
@@ -211,13 +208,17 @@ export function useSetSaveFeedsMutation() {
   })
 }
 
-export function useSaveFeedMutation() {
+export function useAddSavedFeedsMutation() {
   const queryClient = useQueryClient()
   const {getAgent} = useAgent()
 
-  return useMutation<void, unknown, {uri: string}>({
-    mutationFn: async ({uri}) => {
-      await getAgent().addSavedFeed(uri)
+  return useMutation<
+    void,
+    unknown,
+    Pick<AppBskyActorDefs.SavedFeed, 'type' | 'value' | 'pinned'>[]
+  >({
+    mutationFn: async savedFeeds => {
+      await getAgent().addSavedFeeds(savedFeeds)
       track('CustomFeed:Save')
       // triggers a refetch
       await queryClient.invalidateQueries({
@@ -231,9 +232,9 @@ export function useRemoveFeedMutation() {
   const queryClient = useQueryClient()
   const {getAgent} = useAgent()
 
-  return useMutation<void, unknown, {uri: string}>({
-    mutationFn: async ({uri}) => {
-      await getAgent().removeSavedFeed(uri)
+  return useMutation<void, unknown, Pick<AppBskyActorDefs.SavedFeed, 'id'>>({
+    mutationFn: async savedFeed => {
+      await getAgent().removeSavedFeeds([savedFeed.id])
       track('CustomFeed:Unsave')
       // triggers a refetch
       await queryClient.invalidateQueries({
@@ -243,30 +244,14 @@ export function useRemoveFeedMutation() {
   })
 }
 
-export function usePinFeedMutation() {
+export function useUpdateSavedFeedsMutation() {
   const queryClient = useQueryClient()
   const {getAgent} = useAgent()
 
-  return useMutation<void, unknown, {uri: string}>({
-    mutationFn: async ({uri}) => {
-      await getAgent().addPinnedFeed(uri)
-      track('CustomFeed:Pin', {uri})
-      // triggers a refetch
-      await queryClient.invalidateQueries({
-        queryKey: preferencesQueryKey,
-      })
-    },
-  })
-}
-
-export function useUnpinFeedMutation() {
-  const queryClient = useQueryClient()
-  const {getAgent} = useAgent()
+  return useMutation<void, unknown, AppBskyActorDefs.SavedFeed[]>({
+    mutationFn: async feeds => {
+      await getAgent().updateSavedFeeds(feeds)
 
-  return useMutation<void, unknown, {uri: string}>({
-    mutationFn: async ({uri}) => {
-      await getAgent().removePinnedFeed(uri)
-      track('CustomFeed:Unpin', {uri})
       // triggers a refetch
       await queryClient.invalidateQueries({
         queryKey: preferencesQueryKey,
diff --git a/src/state/queries/preferences/types.ts b/src/state/queries/preferences/types.ts
index 96da16f1a..928bb90da 100644
--- a/src/state/queries/preferences/types.ts
+++ b/src/state/queries/preferences/types.ts
@@ -1,7 +1,7 @@
 import {
+  BskyFeedViewPreference,
   BskyPreferences,
   BskyThreadViewPreference,
-  BskyFeedViewPreference,
 } from '@atproto/api'
 
 export type UsePreferencesQueryResponse = Omit<
@@ -16,9 +16,6 @@ export type UsePreferencesQueryResponse = Omit<
    */
   threadViewPrefs: ThreadViewPreferences
   userAge: number | undefined
-  feeds: Required<BskyPreferences['feeds']> & {
-    unpinned: string[]
-  }
 }
 
 export type ThreadViewPreferences = Pick<
diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts
index 024f6e7d1..9633dc0e3 100644
--- a/src/state/session/agent.ts
+++ b/src/state/session/agent.ts
@@ -1,10 +1,15 @@
 import {AtpSessionData, AtpSessionEvent, BskyAgent} from '@atproto/api'
+import {TID} from '@atproto/common-web'
 
 import {networkRetry} from '#/lib/async/retry'
-import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
-import {IS_PROD_SERVICE} from '#/lib/constants'
+import {
+  DISCOVER_SAVED_FEED,
+  IS_PROD_SERVICE,
+  PUBLIC_BSKY_SERVICE,
+  TIMELINE_SAVED_FEED,
+} from '#/lib/constants'
 import {tryFetchGates} from '#/lib/statsig/statsig'
-import {DEFAULT_PROD_FEEDS} from '../queries/preferences'
+import {logger} from '#/logger'
 import {
   configureModerationForAccount,
   configureModerationForGuest,
@@ -134,9 +139,28 @@ export async function createAgentAndCreateAccount(
 
   // Not awaited so that we can still get into onboarding.
   // This is OK because we won't let you toggle adult stuff until you set the date.
-  agent.setPersonalDetails({birthDate: birthDate.toISOString()})
   if (IS_PROD_SERVICE(service)) {
-    agent.setSavedFeeds(DEFAULT_PROD_FEEDS.saved, DEFAULT_PROD_FEEDS.pinned)
+    try {
+      networkRetry(1, async () => {
+        await agent.setPersonalDetails({birthDate: birthDate.toISOString()})
+        await agent.overwriteSavedFeeds([
+          {
+            ...DISCOVER_SAVED_FEED,
+            id: TID.nextStr(),
+          },
+          {
+            ...TIMELINE_SAVED_FEED,
+            id: TID.nextStr(),
+          },
+        ])
+      })
+    } catch (e: any) {
+      logger.error(e, {
+        context: `session: createAgentAndCreateAccount failed to save personal details and feeds`,
+      })
+    }
+  } else {
+    agent.setPersonalDetails({birthDate: birthDate.toISOString()})
   }
 
   return prepareAgent(agent, gates, moderation, onSessionChange)
diff --git a/src/state/shell/selected-feed.tsx b/src/state/shell/selected-feed.tsx
index df50b3952..08b7ba77c 100644
--- a/src/state/shell/selected-feed.tsx
+++ b/src/state/shell/selected-feed.tsx
@@ -1,47 +1,46 @@
 import React from 'react'
 
-import {Gate} from '#/lib/statsig/gates'
-import {useGate} from '#/lib/statsig/statsig'
 import {isWeb} from '#/platform/detection'
 import * as persisted from '#/state/persisted'
+import {FeedDescriptor} from '#/state/queries/post-feed'
 
-type StateContext = string
-type SetContext = (v: string) => void
+type StateContext = FeedDescriptor | null
+type SetContext = (v: FeedDescriptor) => void
 
-const stateContext = React.createContext<StateContext>('home')
+const stateContext = React.createContext<StateContext>(null)
 const setContext = React.createContext<SetContext>((_: string) => {})
 
-function getInitialFeed(gate: (gateName: Gate) => boolean) {
+function getInitialFeed(): FeedDescriptor | null {
   if (isWeb) {
     if (window.location.pathname === '/') {
       const params = new URLSearchParams(window.location.search)
       const feedFromUrl = params.get('feed')
       if (feedFromUrl) {
         // If explicitly booted from a link like /?feed=..., prefer that.
-        return feedFromUrl
+        return feedFromUrl as FeedDescriptor
       }
     }
+
     const feedFromSession = sessionStorage.getItem('lastSelectedHomeFeed')
     if (feedFromSession) {
       // Fall back to a previously chosen feed for this browser tab.
-      return feedFromSession
+      return feedFromSession as FeedDescriptor
     }
   }
-  if (!gate('start_session_with_following_v2')) {
-    const feedFromPersisted = persisted.get('lastSelectedHomeFeed')
-    if (feedFromPersisted) {
-      // Fall back to the last chosen one across all tabs.
-      return feedFromPersisted
-    }
+
+  const feedFromPersisted = persisted.get('lastSelectedHomeFeed')
+  if (feedFromPersisted) {
+    // Fall back to the last chosen one across all tabs.
+    return feedFromPersisted as FeedDescriptor
   }
-  return 'home'
+
+  return null
 }
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
-  const gate = useGate()
-  const [state, setState] = React.useState(() => getInitialFeed(gate))
+  const [state, setState] = React.useState(() => getInitialFeed())
 
-  const saveState = React.useCallback((feed: string) => {
+  const saveState = React.useCallback((feed: FeedDescriptor) => {
     setState(feed)
     if (isWeb) {
       try {
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index bb782809d..6a9fc9346 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
-import {useWindowDimensions, View} from 'react-native'
+import {View} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
@@ -17,9 +18,9 @@ import {useSession} from '#/state/session'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {useComposerControls} from '#/state/shell/composer'
 import {useAnalytics} from 'lib/analytics/analytics'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {ComposeIcon2} from 'lib/icons'
 import {s} from 'lib/styles'
+import {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
 import {Feed} from '../posts/Feed'
 import {FAB} from '../util/fab/FAB'
 import {ListMethods} from '../util/List'
@@ -35,6 +36,7 @@ export function FeedPage({
   feedParams,
   renderEmptyState,
   renderEndOfFeed,
+  savedFeedConfig,
 }: {
   testID?: string
   feed: FeedDescriptor
@@ -42,6 +44,7 @@ export function FeedPage({
   isPageFocused: boolean
   renderEmptyState: () => JSX.Element
   renderEndOfFeed?: () => JSX.Element
+  savedFeedConfig?: AppBskyActorDefs.SavedFeed
 }) {
   const {hasSession} = useSession()
   const {_} = useLingui()
@@ -129,6 +132,7 @@ export function FeedPage({
             renderEmptyState={renderEmptyState}
             renderEndOfFeed={renderEndOfFeed}
             headerOffset={headerOffset}
+            savedFeedConfig={savedFeedConfig}
           />
         </FeedFeedbackProvider>
       </MainScrollProvider>
@@ -153,16 +157,3 @@ export function FeedPage({
     </View>
   )
 }
-
-function useHeaderOffset() {
-  const {isDesktop, isTablet} = useWebMediaQueries()
-  const {fontScale} = useWindowDimensions()
-  if (isDesktop || isTablet) {
-    return 0
-  }
-  const navBarHeight = 42
-  const tabBarPad = 10 + 10 + 3 // padding + border
-  const normalLineHeight = 1.2
-  const tabBarText = 16 * normalLineHeight * fontScale
-  return navBarHeight + tabBarPad + tabBarText
-}
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 8a21d86ae..bb536bccd 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -1,29 +1,30 @@
 import React from 'react'
 import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {Text} from '../util/text/Text'
-import {RichText} from '#/components/RichText'
-import {usePalette} from 'lib/hooks/usePalette'
-import {s} from 'lib/styles'
-import {UserAvatar} from '../util/UserAvatar'
 import {AtUri} from '@atproto/api'
-import * as Toast from 'view/com/util/Toast'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {logger} from '#/logger'
-import {Trans, msg, Plural} from '@lingui/macro'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg, Plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+
+import {logger} from '#/logger'
+import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
 import {
-  usePinFeedMutation,
-  UsePreferencesQueryResponse,
+  useAddSavedFeedsMutation,
   usePreferencesQuery,
-  useSaveFeedMutation,
+  UsePreferencesQueryResponse,
   useRemoveFeedMutation,
 } from '#/state/queries/preferences'
-import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
+import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
+import {usePalette} from 'lib/hooks/usePalette'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {s} from 'lib/styles'
 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 * as Prompt from '#/components/Prompt'
-import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
+import {RichText} from '#/components/RichText'
+import {Text} from '../util/text/Text'
+import {UserAvatar} from '../util/UserAvatar'
 
 export function FeedSourceCard({
   feedUri,
@@ -87,53 +88,54 @@ export function FeedSourceCardLoaded({
   const removePromptControl = Prompt.usePromptControl()
   const navigation = useNavigationDeduped()
 
-  const {isPending: isSavePending, mutateAsync: saveFeed} =
-    useSaveFeedMutation()
+  const {isPending: isAddSavedFeedPending, mutateAsync: addSavedFeeds} =
+    useAddSavedFeedsMutation()
   const {isPending: isRemovePending, mutateAsync: removeFeed} =
     useRemoveFeedMutation()
-  const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
 
-  const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed?.uri || ''))
+  const savedFeedConfig = preferences?.savedFeeds?.find(
+    f => f.value === feed?.uri,
+  )
+  const isSaved = Boolean(savedFeedConfig)
 
   const onSave = React.useCallback(async () => {
-    if (!feed) return
+    if (!feed || isSaved) return
 
     try {
-      if (pinOnSave) {
-        await pinFeed({uri: feed.uri})
-      } else {
-        await saveFeed({uri: feed.uri})
-      }
+      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`))
       logger.error('Failed to save feed', {message: e})
     }
-  }, [_, feed, pinFeed, pinOnSave, saveFeed])
+  }, [_, feed, pinOnSave, addSavedFeeds, isSaved])
 
   const onUnsave = React.useCallback(async () => {
-    if (!feed) return
+    if (!savedFeedConfig) return
 
     try {
-      await removeFeed({uri: feed.uri})
+      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`))
       logger.error('Failed to unsave feed', {message: e})
     }
-  }, [_, feed, removeFeed])
+  }, [_, removeFeed, savedFeedConfig])
 
   const onToggleSaved = React.useCallback(async () => {
-    // Only feeds can be un/saved, lists are handled elsewhere
-    if (feed?.type !== 'feed') return
-
     if (isSaved) {
       removePromptControl.open()
     } else {
       await onSave()
     }
-  }, [feed?.type, isSaved, removePromptControl, onSave])
+  }, [isSaved, removePromptControl, onSave])
 
   /*
    * LOAD STATE
@@ -204,7 +206,7 @@ export function FeedSourceCardLoaded({
           }
         }}
         key={feed.uri}>
-        <View style={[styles.headerContainer]}>
+        <View style={[styles.headerContainer, a.align_start]}>
           <View style={[s.mr10]}>
             <UserAvatar type="algo" size={36} avatar={feed.avatar} />
           </View>
@@ -221,11 +223,11 @@ export function FeedSourceCardLoaded({
             </Text>
           </View>
 
-          {showSaveBtn && feed.type === 'feed' && (
+          {showSaveBtn && (
             <View style={[s.justifyCenter]}>
               <Pressable
                 testID={`feed-${feed.displayName}-toggleSave`}
-                disabled={isSavePending || isPinPending || isRemovePending}
+                disabled={isAddSavedFeedPending || isRemovePending}
                 accessibilityRole="button"
                 accessibilityLabel={
                   isSaved
diff --git a/src/view/com/home/HomeHeader.tsx b/src/view/com/home/HomeHeader.tsx
index aa3ecb7fc..b068484e8 100644
--- a/src/view/com/home/HomeHeader.tsx
+++ b/src/view/com/home/HomeHeader.tsx
@@ -1,12 +1,14 @@
 import React from 'react'
-import {RenderTabBarFnProps} from 'view/com/pager/Pager'
-import {HomeHeaderLayout} from './HomeHeaderLayout'
-import {FeedSourceInfo} from '#/state/queries/feed'
 import {useNavigation} from '@react-navigation/native'
+
+import {usePalette} from '#/lib/hooks/usePalette'
+import {FeedSourceInfo} from '#/state/queries/feed'
+import {useSession} from '#/state/session'
 import {NavigationProp} from 'lib/routes/types'
 import {isWeb} from 'platform/detection'
+import {RenderTabBarFnProps} from 'view/com/pager/Pager'
 import {TabBar} from '../pager/TabBar'
-import {usePalette} from '#/lib/hooks/usePalette'
+import {HomeHeaderLayout} from './HomeHeaderLayout'
 
 export function HomeHeader(
   props: RenderTabBarFnProps & {
@@ -16,12 +18,17 @@ export function HomeHeader(
   },
 ) {
   const {feeds} = props
+  const {hasSession} = useSession()
   const navigation = useNavigation<NavigationProp>()
   const pal = usePalette('default')
 
   const hasPinnedCustom = React.useMemo<boolean>(() => {
-    return feeds.some(tab => tab.uri !== '')
-  }, [feeds])
+    if (!hasSession) return false
+    return feeds.some(tab => {
+      const isFollowing = tab.uri === 'following'
+      return !isFollowing
+    })
+  }, [feeds, hasSession])
 
   const items = React.useMemo(() => {
     const pinnedNames = feeds.map(f => f.displayName)
diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx
index fd4c486af..a95a94835 100644
--- a/src/view/com/lightbox/Lightbox.tsx
+++ b/src/view/com/lightbox/Lightbox.tsx
@@ -1,22 +1,23 @@
 import React from 'react'
 import {LayoutAnimation, StyleSheet, View} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import ImageView from './ImageViewing'
-import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip'
-import * as Toast from '../util/Toast'
-import {Text} from '../util/text/Text'
-import {s, colors} from 'lib/styles'
-import {Button} from '../util/forms/Button'
-import {isIOS} from 'platform/detection'
 import * as MediaLibrary from 'expo-media-library'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
 import {
+  ImagesLightbox,
+  ProfileImageLightbox,
   useLightbox,
   useLightboxControls,
-  ProfileImageLightbox,
-  ImagesLightbox,
 } from '#/state/lightbox'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
+import {saveImageToMediaLibrary, shareImageModal} from 'lib/media/manip'
+import {colors, s} from 'lib/styles'
+import {isIOS} from 'platform/detection'
+import {Button} from '../util/forms/Button'
+import {Text} from '../util/text/Text'
+import * as Toast from '../util/Toast'
+import ImageView from './ImageViewing'
 
 export function Lightbox() {
   const {activeLightbox} = useLightbox()
diff --git a/src/view/com/modals/SelfLabel.tsx b/src/view/com/modals/SelfLabel.tsx
index 2b83c7a9a..ce3fbcef8 100644
--- a/src/view/com/modals/SelfLabel.tsx
+++ b/src/view/com/modals/SelfLabel.tsx
@@ -1,16 +1,17 @@
 import React, {useState} from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {Text} from '../util/text/Text'
-import {s, colors} from 'lib/styles'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useModalControls} from '#/state/modals'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {colors, s} from 'lib/styles'
 import {isWeb} from 'platform/detection'
+import {ScrollView} from 'view/com/modals/util'
 import {Button} from '../util/forms/Button'
 import {SelectableBtn} from '../util/forms/SelectableBtn'
-import {ScrollView} from 'view/com/modals/util'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
+import {Text} from '../util/text/Text'
 
 const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn']
 
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index ff8acd60c..5791e26a9 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -1,11 +1,12 @@
-import React, {useRef, useMemo, useEffect, useState, useCallback} from 'react'
-import {StyleSheet, View, ScrollView, LayoutChangeEvent} from 'react-native'
-import {Text} from '../util/text/Text'
-import {PressableWithHover} from '../util/PressableWithHover'
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
+import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native'
+
+import {isNative} from '#/platform/detection'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {PressableWithHover} from '../util/PressableWithHover'
+import {Text} from '../util/text/Text'
 import {DraggableScrollView} from './DraggableScrollView'
-import {isNative} from '#/platform/detection'
 
 export interface TabBarProps {
   testID?: string
@@ -139,7 +140,10 @@ export function TabBar({
                 <Text
                   type={isDesktop || isTablet ? 'xl-bold' : 'lg-bold'}
                   testID={testID ? `${testID}-${item}` : undefined}
-                  style={selected ? pal.text : pal.textLight}>
+                  style={[
+                    selected ? pal.text : pal.textLight,
+                    {lineHeight: 20},
+                  ]}>
                   {item}
                 </Text>
               </View>
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 8969f7cd2..c51733d1b 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -8,6 +8,7 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
@@ -64,6 +65,7 @@ let Feed = ({
   desktopFixedHeightOffset,
   ListHeaderComponent,
   extraData,
+  savedFeedConfig,
 }: {
   feed: FeedDescriptor
   feedParams?: FeedParams
@@ -82,6 +84,7 @@ let Feed = ({
   desktopFixedHeightOffset?: number
   ListHeaderComponent?: () => JSX.Element
   extraData?: any
+  savedFeedConfig?: AppBskyActorDefs.SavedFeed
 }): React.ReactNode => {
   const theme = useTheme()
   const {track} = useAnalytics()
@@ -140,7 +143,6 @@ let Feed = ({
     if (
       data?.pages.length === 1 &&
       (feed === 'following' ||
-        feed === 'home' ||
         feed === `author|${myDid}|posts_and_author_threads`)
     ) {
       queryClient.invalidateQueries({queryKey: RQKEY(feed)})
@@ -280,6 +282,7 @@ let Feed = ({
             feedDesc={feed}
             error={error ?? undefined}
             onPressTryAgain={onPressTryAgain}
+            savedFeedConfig={savedFeedConfig}
           />
         )
       } else if (item === LOAD_MORE_ERROR_ITEM) {
@@ -302,7 +305,15 @@ let Feed = ({
       }
       return <FeedSlice slice={item} />
     },
-    [feed, error, onPressTryAgain, onPressRetryLoadMore, renderEmptyState, _],
+    [
+      feed,
+      error,
+      onPressTryAgain,
+      onPressRetryLoadMore,
+      renderEmptyState,
+      _,
+      savedFeedConfig,
+    ],
   )
 
   const shouldRenderEndOfFeed =
diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx
index d4ca38d07..a152bc909 100644
--- a/src/view/com/posts/FeedErrorMessage.tsx
+++ b/src/view/com/posts/FeedErrorMessage.tsx
@@ -1,21 +1,22 @@
 import React from 'react'
 import {View} from 'react-native'
-import {AppBskyFeedGetAuthorFeed, AtUri} from '@atproto/api'
-import {Text} from '../util/text/Text'
-import {Button} from '../util/forms/Button'
-import * as Toast from '../util/Toast'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useNavigation} from '@react-navigation/native'
-import {NavigationProp} from 'lib/routes/types'
-import {logger} from '#/logger'
+import {AppBskyActorDefs, AppBskyFeedGetAuthorFeed, AtUri} from '@atproto/api'
 import {msg as msgLingui, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {FeedDescriptor} from '#/state/queries/post-feed'
-import {EmptyState} from '../util/EmptyState'
+import {useNavigation} from '@react-navigation/native'
+
 import {cleanError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+import {FeedDescriptor} from '#/state/queries/post-feed'
 import {useRemoveFeedMutation} from '#/state/queries/preferences'
+import {usePalette} from 'lib/hooks/usePalette'
+import {NavigationProp} from 'lib/routes/types'
 import * as Prompt from '#/components/Prompt'
+import {EmptyState} from '../util/EmptyState'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {Button} from '../util/forms/Button'
+import {Text} from '../util/text/Text'
+import * as Toast from '../util/Toast'
 
 export enum KnownError {
   Block = 'Block',
@@ -33,10 +34,12 @@ export function FeedErrorMessage({
   feedDesc,
   error,
   onPressTryAgain,
+  savedFeedConfig,
 }: {
   feedDesc: FeedDescriptor
   error?: Error
   onPressTryAgain: () => void
+  savedFeedConfig?: AppBskyActorDefs.SavedFeed
 }) {
   const {_: _l} = useLingui()
   const knownError = React.useMemo(
@@ -46,13 +49,15 @@ export function FeedErrorMessage({
   if (
     typeof knownError !== 'undefined' &&
     knownError !== KnownError.Unknown &&
-    (feedDesc.startsWith('feedgen') || knownError === KnownError.FeedNSFPublic)
+    (savedFeedConfig?.type === 'feed' ||
+      knownError === KnownError.FeedNSFPublic)
   ) {
     return (
       <FeedgenErrorMessage
         feedDesc={feedDesc}
         knownError={knownError}
         rawError={error}
+        savedFeedConfig={savedFeedConfig}
       />
     )
   }
@@ -79,10 +84,12 @@ function FeedgenErrorMessage({
   feedDesc,
   knownError,
   rawError,
+  savedFeedConfig,
 }: {
   feedDesc: FeedDescriptor
   knownError: KnownError
   rawError?: Error
+  savedFeedConfig?: AppBskyActorDefs.SavedFeed
 }) {
   const pal = usePalette('default')
   const {_: _l} = useLingui()
@@ -131,7 +138,8 @@ function FeedgenErrorMessage({
 
   const onRemoveFeed = React.useCallback(async () => {
     try {
-      await removeFeed({uri})
+      if (!savedFeedConfig) return
+      await removeFeed(savedFeedConfig)
     } catch (err) {
       Toast.show(
         _l(
@@ -140,7 +148,7 @@ function FeedgenErrorMessage({
       )
       logger.error('Failed to remove feed', {message: err})
     }
-  }, [uri, removeFeed, _l])
+  }, [removeFeed, _l, savedFeedConfig])
 
   const cta = React.useMemo(() => {
     switch (knownError) {
@@ -154,13 +162,14 @@ function FeedgenErrorMessage({
       case KnownError.FeedgenUnknown: {
         return (
           <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}>
-            {knownError === KnownError.FeedgenDoesNotExist && (
-              <Button
-                type="inverted"
-                label={_l(msgLingui`Remove feed`)}
-                onPress={onRemoveFeed}
-              />
-            )}
+            {knownError === KnownError.FeedgenDoesNotExist &&
+              savedFeedConfig && (
+                <Button
+                  type="inverted"
+                  label={_l(msgLingui`Remove feed`)}
+                  onPress={onRemoveFeed}
+                />
+              )}
             <Button
               type="default-light"
               label={_l(msgLingui`View profile`)}
@@ -170,7 +179,7 @@ function FeedgenErrorMessage({
         )
       }
     }
-  }, [knownError, onViewProfile, onRemoveFeed, _l])
+  }, [knownError, onViewProfile, onRemoveFeed, _l, savedFeedConfig])
 
   return (
     <>
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index c1af39a5d..f58417887 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -1,14 +1,15 @@
 import React, {memo, useCallback} from 'react'
 import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native'
+import {msg, plural} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useModalControls} from '#/state/modals'
+import {useRequireAuth} from '#/state/session'
+import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
 import {RepostIcon} from 'lib/icons'
-import {s, colors} from 'lib/styles'
+import {colors, s} from 'lib/styles'
 import {useTheme} from 'lib/ThemeContext'
 import {Text} from '../text/Text'
-import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
-import {useModalControls} from '#/state/modals'
-import {useRequireAuth} from '#/state/session'
-import {msg, plural} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
 
 interface Props {
   isReposted: boolean
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
index 78935edae..826f997dd 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -6,6 +6,7 @@ import {
   StyleSheet,
   View,
 } from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
@@ -44,8 +45,11 @@ import {
 import {Text} from 'view/com/util/text/Text'
 import {UserAvatar} from 'view/com/util/UserAvatar'
 import {ViewHeader} from 'view/com/util/ViewHeader'
+import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
+import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
 import {atoms as a, useTheme} from '#/alf'
 import {IconCircle} from '#/components/IconCircle'
+import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
 import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass'
 import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle'
 
@@ -74,6 +78,7 @@ type FlatlistSlice =
       type: 'savedFeed'
       key: string
       feedUri: string
+      savedFeedConfig: AppBskyActorDefs.SavedFeed
     }
   | {
       type: 'savedFeedsLoadMore'
@@ -100,6 +105,10 @@ type FlatlistSlice =
       type: 'popularFeedsLoadingMore'
       key: string
     }
+  | {
+      type: 'noFollowingFeed'
+      key: string
+    }
 
 // HACK
 // the protocol doesn't yet tell us which feeds are personalized
@@ -229,33 +238,54 @@ export function FeedsScreen(_props: Props) {
           error: cleanError(preferencesError.toString()),
         })
       } else {
-        if (isPreferencesLoading || !preferences?.feeds?.saved) {
+        if (isPreferencesLoading || !preferences?.savedFeeds) {
           slices.push({
             key: 'savedFeedsLoading',
             type: 'savedFeedsLoading',
             // pendingItems: this.rootStore.preferences.savedFeeds.length || 3,
           })
         } else {
-          if (preferences?.feeds?.saved.length !== 0) {
-            const {saved, pinned} = preferences.feeds
+          if (preferences.savedFeeds?.length) {
+            const noFollowingFeed = preferences.savedFeeds.every(
+              f => f.type !== 'timeline',
+            )
 
             slices = slices.concat(
-              pinned.map(uri => ({
-                key: `savedFeed:${uri}`,
-                type: 'savedFeed',
-                feedUri: uri,
-              })),
+              preferences.savedFeeds
+                .filter(f => {
+                  return f.pinned
+                })
+                .map(feed => ({
+                  key: `savedFeed:${feed.value}:${feed.id}`,
+                  type: 'savedFeed',
+                  feedUri: feed.value,
+                  savedFeedConfig: feed,
+                })),
             )
-
             slices = slices.concat(
-              saved
-                .filter(uri => !pinned.includes(uri))
-                .map(uri => ({
-                  key: `savedFeed:${uri}`,
+              preferences.savedFeeds
+                .filter(f => {
+                  return !f.pinned
+                })
+                .map(feed => ({
+                  key: `savedFeed:${feed.value}:${feed.id}`,
                   type: 'savedFeed',
-                  feedUri: uri,
+                  feedUri: feed.value,
+                  savedFeedConfig: feed,
                 })),
             )
+
+            if (noFollowingFeed) {
+              slices.push({
+                key: 'noFollowingFeed',
+                type: 'noFollowingFeed',
+              })
+            }
+          } else {
+            slices.push({
+              key: 'savedFeedNoResults',
+              type: 'savedFeedNoResults',
+            })
           }
         }
       }
@@ -323,7 +353,12 @@ export function FeedsScreen(_props: Props) {
                     ) {
                       return false
                     }
-                    return !preferences?.feeds?.saved.includes(feed.uri)
+                    const alreadySaved = Boolean(
+                      preferences?.savedFeeds?.find(f => {
+                        return f.value === feed.uri
+                      }),
+                    )
+                    return !alreadySaved
                   })
                   .map(feed => ({
                     key: `popularFeed:${feed.uri}`,
@@ -463,23 +498,23 @@ export function FeedsScreen(_props: Props) {
                 </View>
               </View>
             )}
-            {preferences?.feeds?.saved?.length !== 0 && <FeedsSavedHeader />}
+            <FeedsSavedHeader />
           </>
         )
       } else if (item.type === 'savedFeedNoResults') {
         return (
           <View
-            style={{
-              paddingHorizontal: 16,
-              paddingTop: 10,
-            }}>
-            <Text type="lg" style={pal.textLight}>
-              <Trans>You don't have any saved feeds!</Trans>
-            </Text>
+            style={[
+              pal.border,
+              {
+                borderBottomWidth: 1,
+              },
+            ]}>
+            <NoSavedFeedsOfAnyType />
           </View>
         )
       } else if (item.type === 'savedFeed') {
-        return <SavedFeed feedUri={item.feedUri} />
+        return <FeedOrFollowing savedFeedConfig={item.savedFeedConfig} />
       } else if (item.type === 'popularFeedsHeader') {
         return (
           <>
@@ -521,6 +556,18 @@ export function FeedsScreen(_props: Props) {
             </Text>
           </View>
         )
+      } else if (item.type === 'noFollowingFeed') {
+        return (
+          <View
+            style={[
+              pal.border,
+              {
+                borderBottomWidth: 1,
+              },
+            ]}>
+            <NoFollowingFeed />
+          </View>
+        )
       }
       return null
     },
@@ -532,7 +579,6 @@ export function FeedsScreen(_props: Props) {
       pal.icon,
       pal.textLight,
       _,
-      preferences?.feeds?.saved?.length,
       query,
       onChangeQuery,
       onPressCancelSearch,
@@ -585,16 +631,75 @@ export function FeedsScreen(_props: Props) {
   )
 }
 
-function SavedFeed({feedUri}: {feedUri: string}) {
+function FeedOrFollowing({
+  savedFeedConfig: feed,
+}: {
+  savedFeedConfig: AppBskyActorDefs.SavedFeed
+}) {
+  return feed.type === 'timeline' ? (
+    <FollowingFeed />
+  ) : (
+    <SavedFeed savedFeedConfig={feed} />
+  )
+}
+
+function FollowingFeed() {
   const pal = usePalette('default')
+  const t = useTheme()
   const {isMobile} = useWebMediaQueries()
-  const {data: info, error} = useFeedSourceInfoQuery({uri: feedUri})
-  const typeAvatar = getAvatarTypeFromUri(feedUri)
+  return (
+    <View
+      testID={`saved-feed-timeline`}
+      style={[
+        pal.border,
+        styles.savedFeed,
+        isMobile && styles.savedFeedMobile,
+      ]}>
+      <View
+        style={[
+          a.align_center,
+          a.justify_center,
+          {
+            width: 28,
+            height: 28,
+            borderRadius: 3,
+            backgroundColor: t.palette.primary_500,
+          },
+        ]}>
+        <FilterTimeline
+          style={[
+            {
+              width: 18,
+              height: 18,
+            },
+          ]}
+          fill={t.palette.white}
+        />
+      </View>
+      <View
+        style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}>
+        <Text type="lg-medium" style={pal.text} numberOfLines={1}>
+          <Trans>Following</Trans>
+        </Text>
+      </View>
+    </View>
+  )
+}
+
+function SavedFeed({
+  savedFeedConfig: feed,
+}: {
+  savedFeedConfig: AppBskyActorDefs.SavedFeed
+}) {
+  const pal = usePalette('default')
+  const {isMobile} = useWebMediaQueries()
+  const {data: info, error} = useFeedSourceInfoQuery({uri: feed.value})
+  const typeAvatar = getAvatarTypeFromUri(feed.value)
 
   if (!info)
     return (
       <SavedFeedLoadingPlaceholder
-        key={`savedFeedLoadingPlaceholder:${feedUri}`}
+        key={`savedFeedLoadingPlaceholder:${feed.value}`}
       />
     )
 
@@ -632,6 +737,7 @@ function SavedFeed({feedUri}: {feedUri: string}) {
           </View>
         ) : null}
       </View>
+
       {isMobile && (
         <FontAwesomeIcon
           icon="chevron-right"
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 665400f14..bd17e5fe4 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -8,8 +8,8 @@ import {useSetTitle} from '#/lib/hooks/useSetTitle'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {logEvent, LogEvents, useGate} from '#/lib/statsig/statsig'
 import {emitSoftReset} from '#/state/events'
-import {FeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
-import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
+import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
+import {FeedParams} from '#/state/queries/post-feed'
 import {usePreferencesQuery} from '#/state/queries/preferences'
 import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
 import {useSession} from '#/state/session'
@@ -26,6 +26,7 @@ import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
 import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
 import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
 import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
+import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned'
 import {HomeHeader} from '../com/home/HomeHeader'
 
 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
@@ -55,26 +56,16 @@ function HomeScreenReady({
   pinnedFeedInfos,
 }: Props & {
   preferences: UsePreferencesQueryResponse
-  pinnedFeedInfos: FeedSourceInfo[]
+  pinnedFeedInfos: SavedFeedSourceInfo[]
 }) {
   useOTAUpdates()
-
-  const allFeeds = React.useMemo(() => {
-    const feeds: FeedDescriptor[] = []
-    feeds.push('home')
-    for (const {uri} of pinnedFeedInfos) {
-      if (uri.includes('app.bsky.feed.generator')) {
-        feeds.push(`feedgen|${uri}`)
-      } else if (uri.includes('app.bsky.graph.list')) {
-        feeds.push(`list|${uri}`)
-      }
-    }
-    return feeds
-  }, [pinnedFeedInfos])
-
-  const rawSelectedFeed = useSelectedFeed()
+  const allFeeds = React.useMemo(
+    () => pinnedFeedInfos.map(f => f.feedDescriptor),
+    [pinnedFeedInfos],
+  )
+  const rawSelectedFeed = useSelectedFeed() ?? allFeeds[0]
   const setSelectedFeed = useSetSelectedFeed()
-  const maybeFoundIndex = allFeeds.indexOf(rawSelectedFeed as FeedDescriptor)
+  const maybeFoundIndex = allFeeds.indexOf(rawSelectedFeed)
   const selectedIndex = Math.max(0, maybeFoundIndex)
   const selectedFeed = allFeeds[selectedIndex]
 
@@ -107,12 +98,14 @@ function HomeScreenReady({
 
   useFocusEffect(
     useNonReactiveCallback(() => {
-      logEvent('home:feedDisplayed', {
-        index: selectedIndex,
-        feedType: selectedFeed.split('|')[0],
-        feedUrl: selectedFeed,
-        reason: 'focus',
-      })
+      if (selectedFeed) {
+        logEvent('home:feedDisplayed', {
+          index: selectedIndex,
+          feedType: selectedFeed.split('|')[0],
+          feedUrl: selectedFeed,
+          reason: 'focus',
+        })
+      }
     }),
   )
 
@@ -198,12 +191,13 @@ function HomeScreenReady({
     return <CustomFeedEmptyState />
   }, [])
 
-  const [homeFeed, ...customFeeds] = allFeeds
   const homeFeedParams = React.useMemo<FeedParams>(() => {
     return {
       mergeFeedEnabled: Boolean(preferences.feedViewPrefs.lab_mergeFeedEnabled),
       mergeFeedSources: preferences.feedViewPrefs.lab_mergeFeedEnabled
-        ? preferences.feeds.saved
+        ? preferences.savedFeeds
+            .filter(f => f.type === 'feed' || f.type === 'list')
+            .map(f => f.value)
         : [],
     }
   }, [preferences])
@@ -218,26 +212,37 @@ function HomeScreenReady({
       onPageSelected={onPageSelected}
       onPageScrollStateChanged={onPageScrollStateChanged}
       renderTabBar={renderTabBar}>
-      <FeedPage
-        key={homeFeed}
-        testID="followingFeedPage"
-        isPageFocused={selectedFeed === homeFeed}
-        feed={homeFeed}
-        feedParams={homeFeedParams}
-        renderEmptyState={renderFollowingEmptyState}
-        renderEndOfFeed={FollowingEndOfFeed}
-      />
-      {customFeeds.map(feed => {
-        return (
-          <FeedPage
-            key={feed}
-            testID="customFeedPage"
-            isPageFocused={selectedFeed === feed}
-            feed={feed}
-            renderEmptyState={renderCustomFeedEmptyState}
-          />
-        )
-      })}
+      {pinnedFeedInfos.length ? (
+        pinnedFeedInfos.map(feedInfo => {
+          const feed = feedInfo.feedDescriptor
+          if (feed === 'following') {
+            return (
+              <FeedPage
+                key={feed}
+                testID="followingFeedPage"
+                isPageFocused={selectedFeed === feed}
+                feed={feed}
+                feedParams={homeFeedParams}
+                renderEmptyState={renderFollowingEmptyState}
+                renderEndOfFeed={FollowingEndOfFeed}
+              />
+            )
+          }
+          const savedFeedConfig = feedInfo.savedFeed
+          return (
+            <FeedPage
+              key={feed}
+              testID="customFeedPage"
+              isPageFocused={selectedFeed === feed}
+              feed={feed}
+              renderEmptyState={renderCustomFeedEmptyState}
+              savedFeedConfig={savedFeedConfig}
+            />
+          )
+        })
+      ) : (
+        <NoFeedsPinned preferences={preferences} />
+      )}
     </Pager>
   ) : (
     <Pager
diff --git a/src/view/screens/PreferencesFollowingFeed.tsx b/src/view/screens/PreferencesFollowingFeed.tsx
index 724c3f265..b427a0f2b 100644
--- a/src/view/screens/PreferencesFollowingFeed.tsx
+++ b/src/view/screens/PreferencesFollowingFeed.tsx
@@ -1,23 +1,24 @@
 import React, {useState} from 'react'
 import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg, Plural, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {Slider} from '@miblanchard/react-native-slider'
-import {Text} from '../com/util/text/Text'
-import {s, colors} from 'lib/styles'
+import debounce from 'lodash.debounce'
+
+import {
+  usePreferencesQuery,
+  useSetFeedViewPreferencesMutation,
+} from '#/state/queries/preferences'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
+import {colors, s} from 'lib/styles'
 import {isWeb} from 'platform/detection'
 import {ToggleButton} from 'view/com/util/forms/ToggleButton'
-import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
 import {ViewHeader} from 'view/com/util/ViewHeader'
 import {CenteredView} from 'view/com/util/Views'
-import debounce from 'lodash.debounce'
-import {Trans, msg, Plural} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {
-  usePreferencesQuery,
-  useSetFeedViewPreferencesMutation,
-} from '#/state/queries/preferences'
+import {Text} from '../com/util/text/Text'
 
 function RepliesThresholdInput({
   enabled,
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index f66231ab5..3dd8c3ac8 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -16,12 +16,11 @@ import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
 import {FeedDescriptor} from '#/state/queries/post-feed'
 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {
-  usePinFeedMutation,
+  useAddSavedFeedsMutation,
   usePreferencesQuery,
   UsePreferencesQueryResponse,
   useRemoveFeedMutation,
-  useSaveFeedMutation,
-  useUnpinFeedMutation,
+  useUpdateSavedFeedsMutation,
 } from '#/state/queries/preferences'
 import {useResolveUriQuery} from '#/state/queries/resolve-uri'
 import {truncateAndInvalidate} from '#/state/queries/util'
@@ -163,37 +162,20 @@ export function ProfileFeedScreenInner({
   const feedSectionRef = React.useRef<SectionRef>(null)
   const isScreenFocused = useIsFocused()
 
-  const {
-    mutateAsync: saveFeed,
-    variables: savedFeed,
-    reset: resetSaveFeed,
-    isPending: isSavePending,
-  } = useSaveFeedMutation()
-  const {
-    mutateAsync: removeFeed,
-    variables: removedFeed,
-    reset: resetRemoveFeed,
-    isPending: isRemovePending,
-  } = useRemoveFeedMutation()
-  const {
-    mutateAsync: pinFeed,
-    variables: pinnedFeed,
-    reset: resetPinFeed,
-    isPending: isPinPending,
-  } = usePinFeedMutation()
-  const {
-    mutateAsync: unpinFeed,
-    variables: unpinnedFeed,
-    reset: resetUnpinFeed,
-    isPending: isUnpinPending,
-  } = useUnpinFeedMutation()
-
-  const isSaved =
-    !removedFeed &&
-    (!!savedFeed || preferences.feeds.saved.includes(feedInfo.uri))
-  const isPinned =
-    !unpinnedFeed &&
-    (!!pinnedFeed || preferences.feeds.pinned.includes(feedInfo.uri))
+  const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} =
+    useAddSavedFeedsMutation()
+  const {mutateAsync: removeFeed, isPending: isRemovePending} =
+    useRemoveFeedMutation()
+  const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} =
+    useUpdateSavedFeedsMutation()
+
+  const isPending =
+    isAddSavedFeedPending || isRemovePending || isUpdateFeedPending
+  const savedFeedConfig = preferences.savedFeeds.find(
+    f => f.value === feedInfo.uri,
+  )
+  const isSaved = Boolean(savedFeedConfig)
+  const isPinned = Boolean(savedFeedConfig?.pinned)
 
   useSetTitle(feedInfo?.displayName)
 
@@ -204,13 +186,17 @@ export function ProfileFeedScreenInner({
     try {
       playHaptic()
 
-      if (isSaved) {
-        await removeFeed({uri: feedInfo.uri})
-        resetRemoveFeed()
+      if (savedFeedConfig) {
+        await removeFeed(savedFeedConfig)
         Toast.show(_(msg`Removed from your feeds`))
       } else {
-        await saveFeed({uri: feedInfo.uri})
-        resetSaveFeed()
+        await addSavedFeeds([
+          {
+            type: 'feed',
+            value: feedInfo.uri,
+            pinned: false,
+          },
+        ])
         Toast.show(_(msg`Saved to your feeds`))
       }
     } catch (err) {
@@ -221,27 +207,27 @@ export function ProfileFeedScreenInner({
       )
       logger.error('Failed up update feeds', {message: err})
     }
-  }, [
-    playHaptic,
-    isSaved,
-    removeFeed,
-    feedInfo,
-    resetRemoveFeed,
-    _,
-    saveFeed,
-    resetSaveFeed,
-  ])
+  }, [_, playHaptic, feedInfo, removeFeed, addSavedFeeds, savedFeedConfig])
 
   const onTogglePinned = React.useCallback(async () => {
     try {
       playHaptic()
 
-      if (isPinned) {
-        await unpinFeed({uri: feedInfo.uri})
-        resetUnpinFeed()
+      if (savedFeedConfig) {
+        await updateSavedFeeds([
+          {
+            ...savedFeedConfig,
+            pinned: !savedFeedConfig.pinned,
+          },
+        ])
       } else {
-        await pinFeed({uri: feedInfo.uri})
-        resetPinFeed()
+        await addSavedFeeds([
+          {
+            type: 'feed',
+            value: feedInfo.uri,
+            pinned: true,
+          },
+        ])
       }
     } catch (e) {
       Toast.show(_(msg`There was an issue contacting the server`))
@@ -249,13 +235,11 @@ export function ProfileFeedScreenInner({
     }
   }, [
     playHaptic,
-    isPinned,
-    unpinFeed,
     feedInfo,
-    resetUnpinFeed,
-    pinFeed,
-    resetPinFeed,
     _,
+    savedFeedConfig,
+    updateSavedFeeds,
+    addSavedFeeds,
   ])
 
   const onPressShare = React.useCallback(() => {
@@ -296,7 +280,7 @@ export function ProfileFeedScreenInner({
             {feedInfo && hasSession && (
               <NewButton
                 testID={isPinned ? 'unpinBtn' : 'pinBtn'}
-                disabled={isPinPending || isUnpinPending}
+                disabled={isPending}
                 size="small"
                 variant="solid"
                 color={isPinned ? 'secondary' : 'primary'}
@@ -339,7 +323,7 @@ export function ProfileFeedScreenInner({
                   {hasSession && (
                     <>
                       <Menu.Item
-                        disabled={isSavePending || isRemovePending}
+                        disabled={isPending}
                         testID="feedHeaderDropdownToggleSavedBtn"
                         label={
                           isSaved
@@ -395,14 +379,11 @@ export function ProfileFeedScreenInner({
     onTogglePinned,
     onToggleSaved,
     currentAccount?.did,
-    isPinPending,
-    isRemovePending,
-    isSavePending,
     isSaved,
-    isUnpinPending,
     onPressReport,
     onPressShare,
     t,
+    isPending,
   ])
 
   return (
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 2902ccf5e..6bbe63b9e 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -23,10 +23,10 @@ import {
 import {FeedDescriptor} from '#/state/queries/post-feed'
 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {
-  usePinFeedMutation,
+  useAddSavedFeedsMutation,
   usePreferencesQuery,
-  useSetSaveFeedsMutation,
-  useUnpinFeedMutation,
+  useRemoveFeedMutation,
+  useUpdateSavedFeedsMutation,
 } from '#/state/queries/preferences'
 import {useResolveUriQuery} from '#/state/queries/resolve-uri'
 import {truncateAndInvalidate} from '#/state/queries/util'
@@ -248,36 +248,76 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
   const isBlocking = !!list.viewer?.blocked
   const isMuting = !!list.viewer?.muted
   const isOwner = list.creator.did === currentAccount?.did
-  const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
-  const {isPending: isUnpinPending, mutateAsync: unpinFeed} =
-    useUnpinFeedMutation()
-  const isPending = isPinPending || isUnpinPending
   const {data: preferences} = usePreferencesQuery()
-  const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
   const {track} = useAnalytics()
   const playHaptic = useHaptics()
 
+  const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} =
+    useAddSavedFeedsMutation()
+  const {mutateAsync: removeSavedFeed, isPending: isRemovePending} =
+    useRemoveFeedMutation()
+  const {mutateAsync: updateSavedFeeds, isPending: isUpdatingSavedFeeds} =
+    useUpdateSavedFeedsMutation()
+
+  const isPending =
+    isAddSavedFeedPending || isRemovePending || isUpdatingSavedFeeds
+
   const deleteListPromptControl = useDialogControl()
   const subscribeMutePromptControl = useDialogControl()
   const subscribeBlockPromptControl = useDialogControl()
 
-  const isPinned = preferences?.feeds?.pinned?.includes(list.uri)
-  const isSaved = preferences?.feeds?.saved?.includes(list.uri)
+  const savedFeedConfig = preferences?.savedFeeds?.find(
+    f => f.value === list.uri,
+  )
+  const isPinned = Boolean(savedFeedConfig?.pinned)
 
   const onTogglePinned = React.useCallback(async () => {
     playHaptic()
 
     try {
-      if (isPinned) {
-        await unpinFeed({uri: list.uri})
+      if (savedFeedConfig) {
+        const pinned = !savedFeedConfig.pinned
+        await updateSavedFeeds([
+          {
+            ...savedFeedConfig,
+            pinned,
+          },
+        ])
+        Toast.show(_(msg`${pinned ? 'Pinned to' : 'Unpinned from'} your feeds`))
       } else {
-        await pinFeed({uri: list.uri})
+        await addSavedFeeds([
+          {
+            type: 'list',
+            value: list.uri,
+            pinned: true,
+          },
+        ])
+        Toast.show(_(msg`Saved to your feeds`))
       }
     } catch (e) {
       Toast.show(_(msg`There was an issue contacting the server`))
       logger.error('Failed to toggle pinned feed', {message: e})
     }
-  }, [playHaptic, isPinned, unpinFeed, list.uri, pinFeed, _])
+  }, [
+    playHaptic,
+    addSavedFeeds,
+    updateSavedFeeds,
+    list.uri,
+    _,
+    savedFeedConfig,
+  ])
+
+  const onRemoveFromSavedFeeds = React.useCallback(async () => {
+    playHaptic()
+    if (!savedFeedConfig) return
+    try {
+      await removeSavedFeed(savedFeedConfig)
+      Toast.show(_(msg`Removed from your feeds`))
+    } catch (e) {
+      Toast.show(_(msg`There was an issue contacting the server`))
+      logger.error('Failed to remove pinned list', {message: e})
+    }
+  }, [playHaptic, removeSavedFeed, _, savedFeedConfig])
 
   const onSubscribeMute = useCallback(async () => {
     try {
@@ -345,13 +385,8 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
   const onPressDelete = useCallback(async () => {
     await listDeleteMutation.mutateAsync({uri: list.uri})
 
-    if (isSaved || isPinned) {
-      const {saved, pinned} = preferences!.feeds
-
-      setSavedFeeds({
-        saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved,
-        pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned,
-      })
+    if (savedFeedConfig) {
+      await removeSavedFeed(savedFeedConfig)
     }
 
     Toast.show(_(msg`List deleted`))
@@ -367,10 +402,8 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
     navigation,
     track,
     _,
-    preferences,
-    isPinned,
-    isSaved,
-    setSavedFeeds,
+    removeSavedFeed,
+    savedFeedConfig,
   ])
 
   const onPressReport = useCallback(() => {
@@ -398,6 +431,22 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
         },
       },
     ]
+
+    if (savedFeedConfig) {
+      items.push({
+        testID: 'listHeaderDropdownRemoveFromFeedsBtn',
+        label: _(msg`Remove from my feeds`),
+        onPress: onRemoveFromSavedFeeds,
+        icon: {
+          ios: {
+            name: 'trash',
+          },
+          android: '',
+          web: ['far', 'trash-can'],
+        },
+      })
+    }
+
     if (isOwner) {
       items.push({label: 'separator'})
       items.push({
@@ -444,7 +493,10 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
       items.push({
         testID: 'listHeaderDropdownUnpinBtn',
         label: _(msg`Unpin moderation list`),
-        onPress: isPending ? undefined : () => unpinFeed({uri: list.uri}),
+        onPress:
+          isPending || !savedFeedConfig
+            ? undefined
+            : () => removeSavedFeed(savedFeedConfig),
         icon: {
           ios: {
             name: 'pin',
@@ -499,12 +551,13 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
     deleteListPromptControl.open,
     onPressReport,
     isPending,
-    unpinFeed,
-    list.uri,
     isBlocking,
     isMuting,
     onUnsubscribeMute,
     onUnsubscribeBlock,
+    removeSavedFeed,
+    savedFeedConfig,
+    onRemoveFromSavedFeeds,
   ])
 
   const subscribeDropdownItems: DropdownItem[] = useMemo(() => {
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index 0003dbd5d..d50f9f74d 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
 import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -9,11 +10,11 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack'
 import {track} from '#/lib/analytics/analytics'
 import {logger} from '#/logger'
 import {
-  usePinFeedMutation,
+  useOverwriteSavedFeedsMutation,
   usePreferencesQuery,
-  useSetSaveFeedsMutation,
-  useUnpinFeedMutation,
+  useUpdateSavedFeedsMutation,
 } from '#/state/queries/preferences'
+import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useHaptics} from 'lib/haptics'
@@ -27,6 +28,10 @@ import {Text} from 'view/com/util/text/Text'
 import * as Toast from 'view/com/util/Toast'
 import {ViewHeader} from 'view/com/util/ViewHeader'
 import {CenteredView, ScrollView} from 'view/com/util/Views'
+import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
+import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
+import {atoms as a, useTheme} from '#/alf'
+import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
 
 const HITSLOP_TOP = {
   top: 20,
@@ -50,23 +55,25 @@ export function SavedFeeds({}: Props) {
   const setMinimalShellMode = useSetMinimalShellMode()
   const {data: preferences} = usePreferencesQuery()
   const {
-    mutateAsync: setSavedFeeds,
+    mutateAsync: overwriteSavedFeeds,
     variables: optimisticSavedFeedsResponse,
     reset: resetSaveFeedsMutationState,
-    error: setSavedFeedsError,
-  } = useSetSaveFeedsMutation()
+    error: savedFeedsError,
+  } = useOverwriteSavedFeedsMutation()
 
   /*
    * Use optimistic data if exists and no error, otherwise fallback to remote
    * data
    */
   const currentFeeds =
-    optimisticSavedFeedsResponse && !setSavedFeedsError
+    optimisticSavedFeedsResponse && !savedFeedsError
       ? optimisticSavedFeedsResponse
-      : preferences?.feeds || {saved: [], pinned: []}
-  const unpinned = currentFeeds.saved.filter(f => {
-    return !currentFeeds.pinned?.includes(f)
-  })
+      : preferences?.savedFeeds || []
+  const pinnedFeeds = currentFeeds.filter(f => f.pinned)
+  const unpinnedFeeds = currentFeeds.filter(f => !f.pinned)
+  const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0
+  const noFollowingFeed =
+    currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType
 
   useFocusEffect(
     React.useCallback(() => {
@@ -84,14 +91,20 @@ export function SavedFeeds({}: Props) {
       ]}>
       <ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder />
       <ScrollView style={s.flex1} contentContainerStyle={[styles.noBorder]}>
+        {noSavedFeedsOfAnyType && (
+          <View style={[pal.border, {borderBottomWidth: 1}]}>
+            <NoSavedFeedsOfAnyType />
+          </View>
+        )}
+
         <View style={[pal.text, pal.border, styles.title]}>
           <Text type="title" style={pal.text}>
             <Trans>Pinned Feeds</Trans>
           </Text>
         </View>
 
-        {preferences?.feeds ? (
-          !currentFeeds.pinned.length ? (
+        {preferences ? (
+          !pinnedFeeds.length ? (
             <View
               style={[
                 pal.border,
@@ -104,27 +117,35 @@ export function SavedFeeds({}: Props) {
               </Text>
             </View>
           ) : (
-            currentFeeds.pinned.map(uri => (
+            pinnedFeeds.map(f => (
               <ListItem
-                key={uri}
-                feedUri={uri}
+                key={f.id}
+                feed={f}
                 isPinned
-                setSavedFeeds={setSavedFeeds}
+                overwriteSavedFeeds={overwriteSavedFeeds}
                 resetSaveFeedsMutationState={resetSaveFeedsMutationState}
                 currentFeeds={currentFeeds}
+                preferences={preferences}
               />
             ))
           )
         ) : (
           <ActivityIndicator style={{marginTop: 20}} />
         )}
+
+        {noFollowingFeed && (
+          <View style={[pal.border, {borderBottomWidth: 1}]}>
+            <NoFollowingFeed />
+          </View>
+        )}
+
         <View style={[pal.text, pal.border, styles.title]}>
           <Text type="title" style={pal.text}>
             <Trans>Saved Feeds</Trans>
           </Text>
         </View>
-        {preferences?.feeds ? (
-          !unpinned.length ? (
+        {preferences ? (
+          !unpinnedFeeds.length ? (
             <View
               style={[
                 pal.border,
@@ -137,14 +158,15 @@ export function SavedFeeds({}: Props) {
               </Text>
             </View>
           ) : (
-            unpinned.map(uri => (
+            unpinnedFeeds.map(f => (
               <ListItem
-                key={uri}
-                feedUri={uri}
+                key={f.id}
+                feed={f}
                 isPinned={false}
-                setSavedFeeds={setSavedFeeds}
+                overwriteSavedFeeds={overwriteSavedFeeds}
                 resetSaveFeedsMutationState={resetSaveFeedsMutationState}
                 currentFeeds={currentFeeds}
+                preferences={preferences}
               />
             ))
           )
@@ -174,27 +196,29 @@ export function SavedFeeds({}: Props) {
 }
 
 function ListItem({
-  feedUri,
+  feed,
   isPinned,
   currentFeeds,
-  setSavedFeeds,
+  overwriteSavedFeeds,
   resetSaveFeedsMutationState,
 }: {
-  feedUri: string // uri
+  feed: AppBskyActorDefs.SavedFeed
   isPinned: boolean
-  currentFeeds: {saved: string[]; pinned: string[]}
-  setSavedFeeds: ReturnType<typeof useSetSaveFeedsMutation>['mutateAsync']
+  currentFeeds: AppBskyActorDefs.SavedFeed[]
+  overwriteSavedFeeds: ReturnType<
+    typeof useOverwriteSavedFeedsMutation
+  >['mutateAsync']
   resetSaveFeedsMutationState: ReturnType<
-    typeof useSetSaveFeedsMutation
+    typeof useOverwriteSavedFeedsMutation
   >['reset']
+  preferences: UsePreferencesQueryResponse
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
   const playHaptic = useHaptics()
-  const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
-  const {isPending: isUnpinPending, mutateAsync: unpinFeed} =
-    useUnpinFeedMutation()
-  const isPending = isPinPending || isUnpinPending
+  const {isPending: isUpdatePending, mutateAsync: updateSavedFeeds} =
+    useUpdateSavedFeedsMutation()
+  const feedUri = feed.value
 
   const onTogglePinned = React.useCallback(async () => {
     playHaptic()
@@ -202,81 +226,82 @@ function ListItem({
     try {
       resetSaveFeedsMutationState()
 
-      if (isPinned) {
-        await unpinFeed({uri: feedUri})
-      } else {
-        await pinFeed({uri: feedUri})
-      }
+      await updateSavedFeeds([
+        {
+          ...feed,
+          pinned: !feed.pinned,
+        },
+      ])
     } catch (e) {
       Toast.show(_(msg`There was an issue contacting the server`))
       logger.error('Failed to toggle pinned feed', {message: e})
     }
-  }, [
-    playHaptic,
-    resetSaveFeedsMutationState,
-    isPinned,
-    unpinFeed,
-    feedUri,
-    pinFeed,
-    _,
-  ])
+  }, [_, playHaptic, feed, updateSavedFeeds, resetSaveFeedsMutationState])
 
   const onPressUp = React.useCallback(async () => {
     if (!isPinned) return
 
-    // create new array, do not mutate
-    const pinned = [...currentFeeds.pinned]
-    const index = pinned.indexOf(feedUri)
+    const nextFeeds = currentFeeds.slice()
+    const ids = currentFeeds.map(f => f.id)
+    const index = ids.indexOf(feed.id)
+    const nextIndex = index - 1
 
     if (index === -1 || index === 0) return
-    ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]]
+    ;[nextFeeds[index], nextFeeds[nextIndex]] = [
+      nextFeeds[nextIndex],
+      nextFeeds[index],
+    ]
 
     try {
-      await setSavedFeeds({saved: currentFeeds.saved, pinned})
+      await overwriteSavedFeeds(nextFeeds)
       track('CustomFeed:Reorder', {
-        uri: feedUri,
-        index: pinned.indexOf(feedUri),
+        uri: feed.value,
+        index: nextIndex,
       })
     } catch (e) {
       Toast.show(_(msg`There was an issue contacting the server`))
       logger.error('Failed to set pinned feed order', {message: e})
     }
-  }, [feedUri, isPinned, setSavedFeeds, currentFeeds, _])
+  }, [feed, isPinned, overwriteSavedFeeds, currentFeeds, _])
 
   const onPressDown = React.useCallback(async () => {
     if (!isPinned) return
 
-    const pinned = [...currentFeeds.pinned]
-    const index = pinned.indexOf(feedUri)
+    const nextFeeds = currentFeeds.slice()
+    const ids = currentFeeds.map(f => f.id)
+    const index = ids.indexOf(feed.id)
+    const nextIndex = index + 1
 
-    if (index === -1 || index >= pinned.length - 1) return
-    ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]]
+    if (index === -1 || index >= nextFeeds.length - 1) return
+    ;[nextFeeds[index], nextFeeds[nextIndex]] = [
+      nextFeeds[nextIndex],
+      nextFeeds[index],
+    ]
 
     try {
-      await setSavedFeeds({saved: currentFeeds.saved, pinned})
+      await overwriteSavedFeeds(nextFeeds)
       track('CustomFeed:Reorder', {
-        uri: feedUri,
-        index: pinned.indexOf(feedUri),
+        uri: feed.value,
+        index: nextIndex,
       })
     } catch (e) {
       Toast.show(_(msg`There was an issue contacting the server`))
       logger.error('Failed to set pinned feed order', {message: e})
     }
-  }, [feedUri, isPinned, setSavedFeeds, currentFeeds, _])
+  }, [feed, isPinned, overwriteSavedFeeds, currentFeeds, _])
 
   return (
-    <Pressable
-      accessibilityRole="button"
-      style={[styles.itemContainer, pal.border]}>
+    <View style={[styles.itemContainer, pal.border]}>
       {isPinned ? (
         <View style={styles.webArrowButtonsContainer}>
           <Pressable
-            disabled={isPending}
+            disabled={isUpdatePending}
             accessibilityRole="button"
             onPress={onPressUp}
             hitSlop={HITSLOP_TOP}
             style={state => ({
-              opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
+              opacity:
+                state.hovered || state.focused || isUpdatePending ? 0.5 : 1,
             })}>
             <FontAwesomeIcon
               icon="arrow-up"
@@ -285,39 +310,92 @@ function ListItem({
             />
           </Pressable>
           <Pressable
-            disabled={isPending}
+            disabled={isUpdatePending}
             accessibilityRole="button"
             onPress={onPressDown}
             hitSlop={HITSLOP_BOTTOM}
             style={state => ({
-              opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
+              opacity:
+                state.hovered || state.focused || isUpdatePending ? 0.5 : 1,
             })}>
             <FontAwesomeIcon icon="arrow-down" size={12} style={[pal.text]} />
           </Pressable>
         </View>
       ) : null}
-      <FeedSourceCard
-        key={feedUri}
-        feedUri={feedUri}
-        style={styles.noTopBorder}
-        showSaveBtn
-        showMinimalPlaceholder
-      />
-      <Pressable
-        disabled={isPending}
-        accessibilityRole="button"
-        hitSlop={10}
-        onPress={onTogglePinned}
-        style={state => ({
-          opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
-        })}>
-        <FontAwesomeIcon
-          icon="thumb-tack"
-          size={20}
-          color={isPinned ? colors.blue3 : pal.colors.icon}
+      {feed.type === 'timeline' ? (
+        <FollowingFeedCard />
+      ) : (
+        <FeedSourceCard
+          key={feedUri}
+          feedUri={feedUri}
+          style={styles.noTopBorder}
+          showSaveBtn
+          showMinimalPlaceholder
+        />
+      )}
+      <View style={{paddingRight: 16}}>
+        <Pressable
+          disabled={isUpdatePending}
+          accessibilityRole="button"
+          hitSlop={10}
+          onPress={onTogglePinned}
+          style={state => ({
+            opacity:
+              state.hovered || state.focused || isUpdatePending ? 0.5 : 1,
+          })}>
+          <FontAwesomeIcon
+            icon="thumb-tack"
+            size={20}
+            color={isPinned ? colors.blue3 : pal.colors.icon}
+          />
+        </Pressable>
+      </View>
+    </View>
+  )
+}
+
+function FollowingFeedCard() {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.flex_1,
+        {
+          paddingHorizontal: 18,
+          paddingVertical: 20,
+        },
+      ]}>
+      <View
+        style={[
+          a.align_center,
+          a.justify_center,
+          a.rounded_sm,
+          {
+            width: 36,
+            height: 36,
+            backgroundColor: t.palette.primary_500,
+            marginRight: 10,
+          },
+        ]}>
+        <FilterTimeline
+          style={[
+            {
+              width: 22,
+              height: 22,
+            },
+          ]}
+          fill={t.palette.white}
         />
-      </Pressable>
-    </Pressable>
+      </View>
+      <View
+        style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}>
+        <Text type="lg-medium" style={[t.atoms.text]} numberOfLines={1}>
+          <Trans>Following</Trans>
+        </Text>
+      </View>
+    </View>
   )
 }
 
@@ -345,7 +423,6 @@ const styles = StyleSheet.create({
     flexDirection: 'row',
     alignItems: 'center',
     borderBottomWidth: 1,
-    paddingRight: 16,
   },
   webArrowButtonsContainer: {
     paddingLeft: 16,
diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx
index f447490b3..72e34ac46 100644
--- a/src/view/shell/desktop/Feeds.tsx
+++ b/src/view/shell/desktop/Feeds.tsx
@@ -1,16 +1,16 @@
 import React from 'react'
-import {View, StyleSheet} from 'react-native'
-import {useNavigationState, useNavigation} from '@react-navigation/native'
-import {usePalette} from 'lib/hooks/usePalette'
-import {TextLink} from 'view/com/util/Link'
-import {getCurrentRoute} from 'lib/routes/helpers'
-import {useLingui} from '@lingui/react'
+import {StyleSheet, View} from 'react-native'
 import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation, useNavigationState} from '@react-navigation/native'
+
+import {emitSoftReset} from '#/state/events'
 import {usePinnedFeedsInfos} from '#/state/queries/feed'
 import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
-import {FeedDescriptor} from '#/state/queries/post-feed'
+import {usePalette} from 'lib/hooks/usePalette'
+import {getCurrentRoute} from 'lib/routes/helpers'
 import {NavigationProp} from 'lib/routes/types'
-import {emitSoftReset} from '#/state/events'
+import {TextLink} from 'view/com/util/Link'
 
 export function DesktopFeeds() {
   const pal = usePalette('default')
@@ -31,17 +31,7 @@ export function DesktopFeeds() {
   return (
     <View style={[styles.container, pal.view]}>
       {pinnedFeedInfos.map(feedInfo => {
-        const uri = feedInfo.uri
-        let feed: FeedDescriptor
-        if (!uri) {
-          feed = 'home'
-        } else if (uri.includes('app.bsky.feed.generator')) {
-          feed = `feedgen|${uri}`
-        } else if (uri.includes('app.bsky.graph.list')) {
-          feed = `list|${uri}`
-        } else {
-          return null
-        }
+        const feed = feedInfo.feedDescriptor
         return (
           <FeedItem
             key={feed}