about summary refs log tree commit diff
path: root/src/view/screens
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/screens')
-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
6 files changed, 494 insertions, 271 deletions
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,