about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-05-18 11:51:25 -0500
committerPaul Frazee <pfrazee@gmail.com>2023-05-18 11:51:25 -0500
commit7691fe4f481bf08c711cf92da91b2c204d121a7f (patch)
treeb73b64c6f051c79aae922642897e1a8fea51aaac /src
parentd88c27a41995c181a38c01248fe01f853ba83366 (diff)
downloadvoidsky-7691fe4f481bf08c711cf92da91b2c204d121a7f.tar.zst
Store/sync pinned feeds on the server
Diffstat (limited to 'src')
-rw-r--r--src/state/models/me.ts7
-rw-r--r--src/state/models/ui/preferences.ts58
-rw-r--r--src/state/models/ui/saved-feeds.ts226
-rw-r--r--src/view/com/feeds/CustomFeed.tsx22
-rw-r--r--src/view/com/feeds/SavedFeeds.tsx6
-rw-r--r--src/view/com/pager/Pager.web.tsx99
-rw-r--r--src/view/screens/Home.tsx16
-rw-r--r--src/view/screens/SavedFeeds.tsx84
8 files changed, 278 insertions, 240 deletions
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index 9b2b96832..815044857 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -69,7 +69,6 @@ export class MeModel {
       displayName: this.displayName,
       description: this.description,
       avatar: this.avatar,
-      savedFeeds: this.savedFeeds.serialize(),
     }
   }
 
@@ -91,9 +90,6 @@ export class MeModel {
       if (hasProp(v, 'avatar') && typeof v.avatar === 'string') {
         avatar = v.avatar
       }
-      if (hasProp(v, 'savedFeeds') && isObj(v.savedFeeds)) {
-        this.savedFeeds.hydrate(v.savedFeeds)
-      }
       if (did && handle) {
         this.did = did
         this.handle = handle
@@ -118,7 +114,7 @@ export class MeModel {
       /* dont await */ this.notifications.setup().catch(e => {
         this.rootStore.log.error('Failed to setup notifications model', e)
       })
-      /* dont await */ this.savedFeeds.refresh()
+      /* dont await */ this.savedFeeds.refresh(true)
       this.rootStore.emitSessionLoaded()
       await this.fetchInviteCodes()
       await this.fetchAppPasswords()
@@ -128,6 +124,7 @@ export class MeModel {
   }
 
   async updateIfNeeded() {
+    /* dont await */ this.savedFeeds.refresh(true)
     if (Date.now() - this.lastProfileStateUpdate > PROFILE_UPDATE_INTERVAL) {
       this.rootStore.log.debug('Updating me profile information')
       this.lastProfileStateUpdate = Date.now()
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index 1471420fc..05a1eb128 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -25,6 +25,7 @@ const LABEL_GROUPS = [
   'spam',
   'impersonation',
 ]
+const VISIBILITY_VALUES = ['show', 'warn', 'hide']
 
 export class LabelPreferencesModel {
   nsfw: LabelPreference = 'hide'
@@ -45,6 +46,7 @@ export class PreferencesModel {
   contentLanguages: string[] =
     deviceLocales?.map?.(locale => locale.languageCode) || []
   contentLabels = new LabelPreferencesModel()
+  pinnedFeeds: string[] = []
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(this, {}, {autoBind: true})
@@ -54,6 +56,7 @@ export class PreferencesModel {
     return {
       contentLanguages: this.contentLanguages,
       contentLabels: this.contentLabels,
+      pinnedFeeds: this.pinnedFeeds,
     }
   }
 
@@ -72,6 +75,13 @@ export class PreferencesModel {
         // default to the device languages
         this.contentLanguages = deviceLocales.map(locale => locale.languageCode)
       }
+      if (
+        hasProp(v, 'pinnedFeeds') &&
+        Array.isArray(v.pinnedFeeds) &&
+        typeof v.pinnedFeeds.every(item => typeof item === 'string')
+      ) {
+        this.pinnedFeeds = v.pinnedFeeds
+      }
     }
   }
 
@@ -88,9 +98,18 @@ export class PreferencesModel {
           AppBskyActorDefs.isContentLabelPref(pref) &&
           AppBskyActorDefs.validateAdultContentPref(pref).success
         ) {
-          if (LABEL_GROUPS.includes(pref.label)) {
-            this.contentLabels[pref.label] = pref.visibility
+          if (
+            LABEL_GROUPS.includes(pref.label) &&
+            VISIBILITY_VALUES.includes(pref.visibility)
+          ) {
+            this.contentLabels[pref.label as keyof LabelPreferencesModel] =
+              pref.visibility as LabelPreference
           }
+        } else if (
+          AppBskyActorDefs.isPinnedFeedsPref(pref) &&
+          AppBskyActorDefs.validatePinnedFeedsPref(pref).success
+        ) {
+          this.pinnedFeeds = pref.feeds
         }
       }
     })
@@ -200,4 +219,39 @@ export class PreferencesModel {
     }
     return res
   }
+
+  async setPinnedFeeds(v: string[]) {
+    const old = this.pinnedFeeds
+    this.pinnedFeeds = v
+    try {
+      await this.update((prefs: AppBskyActorDefs.Preferences) => {
+        const existing = prefs.find(
+          pref =>
+            AppBskyActorDefs.isPinnedFeedsPref(pref) &&
+            AppBskyActorDefs.validatePinnedFeedsPref(pref).success,
+        )
+        if (existing) {
+          existing.feeds = v
+        } else {
+          prefs.push({
+            $type: 'app.bsky.actor.defs#pinnedFeedsPref',
+            feeds: v,
+          })
+        }
+      })
+    } catch (e) {
+      runInAction(() => {
+        this.pinnedFeeds = old
+      })
+      throw e
+    }
+  }
+
+  async addPinnedFeed(v: string) {
+    return this.setPinnedFeeds([...this.pinnedFeeds, v])
+  }
+
+  async removePinnedFeed(v: string) {
+    return this.setPinnedFeeds(this.pinnedFeeds.filter(uri => uri !== v))
+  }
 }
diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts
index d68664c2d..f500aef2e 100644
--- a/src/state/models/ui/saved-feeds.ts
+++ b/src/state/models/ui/saved-feeds.ts
@@ -1,12 +1,11 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {AppBskyFeedGetSavedFeeds as GetSavedFeeds} from '@atproto/api'
+import {AppBskyFeedDefs} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {bundleAsync} from 'lib/async/bundle'
 import {cleanError} from 'lib/strings/errors'
 import {CustomFeedModel} from '../feeds/custom-feed'
-import {hasProp, isObj} from 'lib/type-guards'
 
-const PAGE_SIZE = 30
+const PAGE_SIZE = 100
 
 export class SavedFeedsModel {
   // state
@@ -14,12 +13,9 @@ export class SavedFeedsModel {
   isRefreshing = false
   hasLoaded = false
   error = ''
-  hasMore = true
-  loadMoreCursor?: string
 
   // data
   feeds: CustomFeedModel[] = []
-  pinned: CustomFeedModel[] = []
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(
@@ -31,24 +27,6 @@ export class SavedFeedsModel {
     )
   }
 
-  serialize() {
-    return {
-      pinned: this.pinned.map(f => f.serialize()),
-    }
-  }
-
-  hydrate(v: unknown) {
-    if (isObj(v)) {
-      if (hasProp(v, 'pinned')) {
-        const pinnedSerialized = (v as any).pinned as string[]
-        const pinnedDeserialized = pinnedSerialized.map(
-          (s: string) => new CustomFeedModel(this.rootStore, JSON.parse(s)),
-        )
-        this.pinned = pinnedDeserialized
-      }
-    }
-  }
-
   get hasContent() {
     return this.feeds.length > 0
   }
@@ -61,149 +39,121 @@ export class SavedFeedsModel {
     return this.hasLoaded && !this.hasContent
   }
 
-  get numFeeds() {
-    return this.feeds.length
+  get pinned() {
+    return this.rootStore.preferences.pinnedFeeds
+      .map(uri => this.feeds.find(f => f.uri === uri) as CustomFeedModel)
+      .filter(Boolean)
   }
 
   get unpinned() {
-    return this.feeds.filter(
-      f => !this.pinned.find(p => p.data.uri === f.data.uri),
-    )
-  }
-
-  get feedNames() {
-    return this.feeds.map(f => f.displayName)
+    return this.feeds.filter(f => !this.isPinned(f))
   }
 
   get pinnedFeedNames() {
     return this.pinned.map(f => f.displayName)
   }
 
-  togglePinnedFeed(feed: CustomFeedModel) {
-    if (!this.isPinned(feed)) {
-      this.pinned = [...this.pinned, feed]
-    } else {
-      this.removePinnedFeed(feed.data.uri)
-    }
-  }
-
-  removePinnedFeed(uri: string) {
-    this.pinned = this.pinned.filter(f => f.data.uri !== uri)
-  }
-
-  reorderPinnedFeeds(temp: CustomFeedModel[]) {
-    this.pinned = temp.filter(item => this.isPinned(item))
-  }
-
-  isPinned(feed: CustomFeedModel) {
-    return this.pinned.find(f => f.data.uri === feed.data.uri) ? true : false
-  }
-
-  movePinnedItem(item: CustomFeedModel, direction: 'up' | 'down') {
-    if (this.pinned.length < 2) {
-      throw new Error('Array must have at least 2 items')
-    }
-    const index = this.pinned.indexOf(item)
-    if (index === -1) {
-      throw new Error('Item not found in array')
-    }
-
-    const len = this.pinned.length
-
-    runInAction(() => {
-      if (direction === 'up') {
-        if (index === 0) {
-          // Remove the item from the first place and put it at the end
-          this.pinned.push(this.pinned.shift()!)
-        } else {
-          // Swap the item with the one before it
-          const temp = this.pinned[index]
-          this.pinned[index] = this.pinned[index - 1]
-          this.pinned[index - 1] = temp
-        }
-      } else if (direction === 'down') {
-        if (index === len - 1) {
-          // Remove the item from the last place and put it at the start
-          this.pinned.unshift(this.pinned.pop()!)
-        } else {
-          // Swap the item with the one after it
-          const temp = this.pinned[index]
-          this.pinned[index] = this.pinned[index + 1]
-          this.pinned[index + 1] = temp
-        }
-      }
-      // this.pinned = [...this.pinned]
-    })
-  }
-
   // public api
   // =
 
-  async refresh(quietRefresh = false) {
-    return this.loadMore(true, quietRefresh)
-  }
-
   clear() {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = false
     this.error = ''
-    this.hasMore = true
-    this.loadMoreCursor = undefined
     this.feeds = []
   }
 
-  loadMore = bundleAsync(
-    async (replace: boolean = false, quietRefresh = false) => {
-      if (!replace && !this.hasMore) {
-        return
-      }
-      this._xLoading(replace && !quietRefresh)
-      try {
+  refresh = bundleAsync(async (quietRefresh = false) => {
+    this._xLoading(!quietRefresh)
+    try {
+      let feeds: AppBskyFeedDefs.GeneratorView[] = []
+      let cursor
+      for (let i = 0; i < 100; i++) {
         const res = await this.rootStore.agent.app.bsky.feed.getSavedFeeds({
           limit: PAGE_SIZE,
-          cursor: replace ? undefined : this.loadMoreCursor,
+          cursor,
         })
-        if (replace) {
-          this._replaceAll(res)
-        } else {
-          this._appendAll(res)
+        feeds = feeds.concat(res.data.feeds)
+        cursor = res.data.cursor
+        if (!cursor) {
+          break
         }
-        this._xIdle()
-      } catch (e: any) {
-        this._xIdle(e)
       }
-    },
-  )
-
-  removeFeed(uri: string) {
-    this.feeds = this.feeds.filter(f => f.data.uri !== uri)
-  }
-
-  addFeed(algoItem: CustomFeedModel) {
-    this.feeds.push(new CustomFeedModel(this.rootStore, algoItem.data))
-  }
+      runInAction(() => {
+        this.feeds = feeds.map(f => new CustomFeedModel(this.rootStore, f))
+      })
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
 
-  async save(algoItem: CustomFeedModel) {
+  async save(feed: CustomFeedModel) {
     try {
-      await algoItem.save()
-      this.addFeed(algoItem)
+      await feed.save()
+      runInAction(() => {
+        this.feeds = [
+          ...this.feeds,
+          new CustomFeedModel(this.rootStore, feed.data),
+        ]
+      })
     } catch (e: any) {
       this.rootStore.log.error('Failed to save feed', e)
     }
   }
 
-  async unsave(algoItem: CustomFeedModel) {
-    const uri = algoItem.uri
+  async unsave(feed: CustomFeedModel) {
+    const uri = feed.uri
     try {
-      await algoItem.unsave()
-      this.removeFeed(uri)
-      this.removePinnedFeed(uri)
+      if (this.isPinned(feed)) {
+        await this.rootStore.preferences.removePinnedFeed(uri)
+      }
+      await feed.unsave()
+      runInAction(() => {
+        this.feeds = this.feeds.filter(f => f.data.uri !== uri)
+      })
     } catch (e: any) {
       this.rootStore.log.error('Failed to unsave feed', e)
     }
   }
 
+  async togglePinnedFeed(feed: CustomFeedModel) {
+    if (!this.isPinned(feed)) {
+      return this.rootStore.preferences.addPinnedFeed(feed.uri)
+    } else {
+      return this.rootStore.preferences.removePinnedFeed(feed.uri)
+    }
+  }
+
+  async reorderPinnedFeeds(feeds: CustomFeedModel[]) {
+    return this.rootStore.preferences.setPinnedFeeds(
+      feeds.filter(feed => this.isPinned(feed)).map(feed => feed.uri),
+    )
+  }
+
+  isPinned(feed: CustomFeedModel) {
+    return this.rootStore.preferences.pinnedFeeds.includes(feed.uri)
+  }
+
+  async movePinnedFeed(item: CustomFeedModel, direction: 'up' | 'down') {
+    const pinned = this.rootStore.preferences.pinnedFeeds.slice()
+    const index = pinned.indexOf(item.uri)
+    if (index === -1) {
+      return
+    }
+    if (direction === 'up' && index !== 0) {
+      const temp = pinned[index]
+      pinned[index] = pinned[index - 1]
+      pinned[index - 1] = temp
+    } else if (direction === 'down' && index < pinned.length - 1) {
+      const temp = pinned[index]
+      pinned[index] = pinned[index + 1]
+      pinned[index + 1] = temp
+    }
+    await this.rootStore.preferences.setPinnedFeeds(pinned)
+  }
+
   // state transitions
   // =
 
@@ -219,23 +169,7 @@ export class SavedFeedsModel {
     this.hasLoaded = true
     this.error = cleanError(err)
     if (err) {
-      this.rootStore.log.error('Failed to fetch user followers', err)
-    }
-  }
-
-  // helper functions
-  // =
-
-  _replaceAll(res: GetSavedFeeds.Response) {
-    this.feeds = []
-    this._appendAll(res)
-  }
-
-  _appendAll(res: GetSavedFeeds.Response) {
-    this.loadMoreCursor = res.data.cursor
-    this.hasMore = !!this.loadMoreCursor
-    for (const f of res.data.feeds) {
-      this.feeds.push(new CustomFeedModel(this.rootStore, f))
+      this.rootStore.log.error('Failed to fetch user feeds', err)
     }
   }
 }
diff --git a/src/view/com/feeds/CustomFeed.tsx b/src/view/com/feeds/CustomFeed.tsx
index 911c33da4..d4e843b67 100644
--- a/src/view/com/feeds/CustomFeed.tsx
+++ b/src/view/com/feeds/CustomFeed.tsx
@@ -39,20 +39,30 @@ export const CustomFeed = observer(
     const pal = usePalette('default')
     const navigation = useNavigation<NavigationProp>()
 
-    const onToggleSaved = React.useCallback(() => {
+    const onToggleSaved = React.useCallback(async () => {
       if (item.data.viewer?.saved) {
         store.shell.openModal({
           name: 'confirm',
           title: 'Remove from my feeds',
           message: `Remove ${item.displayName} from my feeds?`,
-          onPressConfirm: () => {
-            store.me.savedFeeds.unsave(item)
-            Toast.show('Removed from my feeds')
+          onPressConfirm: async () => {
+            try {
+              await store.me.savedFeeds.unsave(item)
+              Toast.show('Removed from my feeds')
+            } catch (e) {
+              Toast.show('There was an issue contacting your server')
+              store.log.error('Failed to unsave feed', {e})
+            }
           },
         })
       } else {
-        store.me.savedFeeds.save(item)
-        Toast.show('Added to my feeds')
+        try {
+          await store.me.savedFeeds.save(item)
+          Toast.show('Added to my feeds')
+        } catch (e) {
+          Toast.show('There was an issue contacting your server')
+          store.log.error('Failed to save feed', {e})
+        }
       }
     }, [store, item])
 
diff --git a/src/view/com/feeds/SavedFeeds.tsx b/src/view/com/feeds/SavedFeeds.tsx
index 7135fdf0a..1cb109a43 100644
--- a/src/view/com/feeds/SavedFeeds.tsx
+++ b/src/view/com/feeds/SavedFeeds.tsx
@@ -29,6 +29,10 @@ export const SavedFeeds = observer(
       }
     }, [store, isPageFocused])
 
+    const onRefresh = useCallback(() => {
+      store.me.savedFeeds.refresh()
+    }, [store])
+
     const renderListEmptyComponent = useCallback(() => {
       return (
         <View
@@ -73,7 +77,7 @@ export const SavedFeeds = observer(
         refreshControl={
           <RefreshControl
             refreshing={store.me.savedFeeds.isRefreshing}
-            onRefresh={() => store.me.savedFeeds.refresh()}
+            onRefresh={onRefresh}
             tintColor={pal.colors.text}
             titleColor={pal.colors.text}
             progressViewOffset={headerOffset}
diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx
index 107497f6f..7be2b11ec 100644
--- a/src/view/com/pager/Pager.web.tsx
+++ b/src/view/com/pager/Pager.web.tsx
@@ -1,12 +1,9 @@
 import React from 'react'
-import {Animated, View} from 'react-native'
-import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
+import {View} from 'react-native'
 import {s} from 'lib/styles'
 
 export interface RenderTabBarFnProps {
   selectedPage: number
-  position: Animated.Value
-  offset: Animated.Value
   onSelect?: (index: number) => void
 }
 export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
@@ -17,53 +14,51 @@ interface Props {
   renderTabBar: RenderTabBarFn
   onPageSelected?: (index: number) => void
 }
-export const Pager = ({
-  children,
-  tabBarPosition = 'top',
-  initialPage = 0,
-  renderTabBar,
-  onPageSelected,
-}: React.PropsWithChildren<Props>) => {
-  const [selectedPage, setSelectedPage] = React.useState(initialPage)
-  const position = useAnimatedValue(0)
-  const offset = useAnimatedValue(0)
+export const Pager = React.forwardRef(
+  (
+    {
+      children,
+      tabBarPosition = 'top',
+      initialPage = 0,
+      renderTabBar,
+      onPageSelected,
+    }: React.PropsWithChildren<Props>,
+    ref,
+  ) => {
+    const [selectedPage, setSelectedPage] = React.useState(initialPage)
 
-  const onTabBarSelect = React.useCallback(
-    (index: number) => {
-      setSelectedPage(index)
-      onPageSelected?.(index)
-      Animated.timing(position, {
-        toValue: index,
-        duration: 200,
-        useNativeDriver: true,
-      }).start()
-    },
-    [setSelectedPage, onPageSelected, position],
-  )
+    React.useImperativeHandle(ref, () => ({
+      setPage: (index: number) => setSelectedPage(index),
+    }))
 
-  return (
-    <View>
-      {tabBarPosition === 'top' &&
-        renderTabBar({
-          selectedPage,
-          position,
-          offset,
-          onSelect: onTabBarSelect,
-        })}
-      {React.Children.map(children, (child, i) => (
-        <View
-          style={selectedPage === i ? undefined : s.hidden}
-          key={`page-${i}`}>
-          {child}
-        </View>
-      ))}
-      {tabBarPosition === 'bottom' &&
-        renderTabBar({
-          selectedPage,
-          position,
-          offset,
-          onSelect: onTabBarSelect,
-        })}
-    </View>
-  )
-}
+    const onTabBarSelect = React.useCallback(
+      (index: number) => {
+        setSelectedPage(index)
+        onPageSelected?.(index)
+      },
+      [setSelectedPage, onPageSelected],
+    )
+
+    return (
+      <View>
+        {tabBarPosition === 'top' &&
+          renderTabBar({
+            selectedPage,
+            onSelect: onTabBarSelect,
+          })}
+        {React.Children.map(children, (child, i) => (
+          <View
+            style={selectedPage === i ? undefined : s.hidden}
+            key={`page-${i}`}>
+            {child}
+          </View>
+        ))}
+        {tabBarPosition === 'bottom' &&
+          renderTabBar({
+            selectedPage,
+            onSelect: onTabBarSelect,
+          })}
+      </View>
+    )
+  },
+)
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 644182126..54cec3b31 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -4,6 +4,7 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'
 import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api'
 import {observer} from 'mobx-react-lite'
 import useAppState from 'react-native-appstate-hook'
+import isEqual from 'lodash.isequal'
 import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
 import {PostsFeedModel} from 'state/models/feeds/posts'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
@@ -44,15 +45,26 @@ export const HomeScreen = withAuthRequired(
     }, [store])
 
     React.useEffect(() => {
+      const {pinned} = store.me.savedFeeds
+      if (
+        isEqual(
+          pinned.map(p => p.uri),
+          customFeeds.map(f => (f.params as GetCustomFeed.QueryParams).feed),
+        )
+      ) {
+        // no changes
+        return
+      }
+
       const feeds = []
-      for (const feed of store.me.savedFeeds.pinned) {
+      for (const feed of pinned) {
         const model = new PostsFeedModel(store, 'custom', {feed: feed.uri})
         model.setup()
         feeds.push(model)
       }
       pagerRef.current?.setPage(0)
       setCustomFeeds(feeds)
-    }, [store, store.me.savedFeeds.pinned, setCustomFeeds])
+    }, [store, store.me.savedFeeds.pinned, customFeeds, setCustomFeeds])
 
     React.useEffect(() => {
       // refresh whats hot when lang preferences change
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index c2723f694..613e42fbf 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -27,26 +27,26 @@ import DraggableFlatList, {
 import {CustomFeed} from 'view/com/feeds/CustomFeed'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {CustomFeedModel} from 'state/models/feeds/custom-feed'
+import * as Toast from 'view/com/util/Toast'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
 
 export const SavedFeeds = withAuthRequired(
   observer(({}: Props) => {
-    // hooks for global items
     const pal = usePalette('default')
-    const rootStore = useStores()
+    const store = useStores()
     const {screen} = useAnalytics()
 
-    // hooks for local
-    const savedFeeds = useMemo(() => rootStore.me.savedFeeds, [rootStore])
+    const savedFeeds = useMemo(() => store.me.savedFeeds, [store])
     useFocusEffect(
       useCallback(() => {
         screen('SavedFeeds')
-        rootStore.shell.setMinimalShellMode(false)
+        store.shell.setMinimalShellMode(false)
         savedFeeds.refresh()
-      }, [screen, rootStore, savedFeeds]),
+      }, [screen, store, savedFeeds]),
     )
-    const _ListEmptyComponent = () => {
+
+    const renderListEmptyComponent = useCallback(() => {
       return (
         <View
           style={[
@@ -56,19 +56,33 @@ export const SavedFeeds = withAuthRequired(
             styles.empty,
           ]}>
           <Text type="lg" style={[pal.text]}>
-            You don't have any pinned feeds. To pin a feed, go back to the Saved
-            Feeds screen and click the pin icon!
+            You don't have any saved feeds.
           </Text>
         </View>
       )
-    }
-    const _ListFooterComponent = () => {
+    }, [pal])
+
+    const renderListFooterComponent = useCallback(() => {
       return (
         <View style={styles.footer}>
           {savedFeeds.isLoading && <ActivityIndicator />}
         </View>
       )
-    }
+    }, [savedFeeds])
+
+    const onRefresh = useCallback(() => savedFeeds.refresh(), [savedFeeds])
+
+    const onDragEnd = useCallback(
+      async ({data}) => {
+        try {
+          await savedFeeds.reorderPinnedFeeds(data)
+        } catch (e) {
+          Toast.show('There was an issue contacting the server')
+          store.log.error('Failed to save pinned feed order', {e})
+        }
+      },
+      [savedFeeds, store],
+    )
 
     return (
       <CenteredView
@@ -90,17 +104,17 @@ export const SavedFeeds = withAuthRequired(
           refreshControl={
             <RefreshControl
               refreshing={savedFeeds.isRefreshing}
-              onRefresh={() => savedFeeds.refresh()}
+              onRefresh={onRefresh}
               tintColor={pal.colors.text}
               titleColor={pal.colors.text}
             />
           }
           renderItem={({item, drag}) => <ListItem item={item} drag={drag} />}
           initialNumToRender={10}
-          ListFooterComponent={_ListFooterComponent}
-          ListEmptyComponent={_ListEmptyComponent}
+          ListFooterComponent={renderListFooterComponent}
+          ListEmptyComponent={renderListEmptyComponent}
           extraData={savedFeeds.isLoading}
-          onDragEnd={({data}) => savedFeeds.reorderPinnedFeeds(data)}
+          onDragEnd={onDragEnd}
         />
       </CenteredView>
     )
@@ -110,13 +124,35 @@ export const SavedFeeds = withAuthRequired(
 const ListItem = observer(
   ({item, drag}: {item: CustomFeedModel; drag: () => void}) => {
     const pal = usePalette('default')
-    const rootStore = useStores()
-    const savedFeeds = useMemo(() => rootStore.me.savedFeeds, [rootStore])
+    const store = useStores()
+    const savedFeeds = useMemo(() => store.me.savedFeeds, [store])
     const isPinned = savedFeeds.isPinned(item)
+
     const onTogglePinned = useCallback(
-      () => savedFeeds.togglePinnedFeed(item),
-      [savedFeeds, item],
+      () =>
+        savedFeeds.togglePinnedFeed(item).catch(e => {
+          Toast.show('There was an issue contacting the server')
+          store.log.error('Failed to toggle pinned feed', {e})
+        }),
+      [savedFeeds, item, store],
+    )
+    const onPressUp = useCallback(
+      () =>
+        savedFeeds.movePinnedFeed(item, 'up').catch(e => {
+          Toast.show('There was an issue contacting the server')
+          store.log.error('Failed to set pinned feed order', {e})
+        }),
+      [store, savedFeeds, item],
     )
+    const onPressDown = useCallback(
+      () =>
+        savedFeeds.movePinnedFeed(item, 'down').catch(e => {
+          Toast.show('There was an issue contacting the server')
+          store.log.error('Failed to set pinned feed order', {e})
+        }),
+      [store, savedFeeds, item],
+    )
+
     return (
       <ScaleDecorator>
         <ShadowDecorator>
@@ -128,9 +164,7 @@ const ListItem = observer(
               <View style={styles.webArrowButtonsContainer}>
                 <TouchableOpacity
                   accessibilityRole="button"
-                  onPress={() => {
-                    savedFeeds.movePinnedItem(item, 'up')
-                  }}>
+                  onPress={onPressUp}>
                   <FontAwesomeIcon
                     icon="arrow-up"
                     size={12}
@@ -139,9 +173,7 @@ const ListItem = observer(
                 </TouchableOpacity>
                 <TouchableOpacity
                   accessibilityRole="button"
-                  onPress={() => {
-                    savedFeeds.movePinnedItem(item, 'down')
-                  }}>
+                  onPress={onPressDown}>
                   <FontAwesomeIcon
                     icon="arrow-down"
                     size={12}