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/CustomFeed.tsx418
-rw-r--r--src/view/screens/CustomFeedLikedBy.tsx29
-rw-r--r--src/view/screens/DiscoverFeeds.tsx112
-rw-r--r--src/view/screens/Feeds.tsx126
-rw-r--r--src/view/screens/Home.tsx87
-rw-r--r--src/view/screens/ModerationMutedAccounts.tsx2
-rw-r--r--src/view/screens/Notifications.tsx21
-rw-r--r--src/view/screens/Profile.tsx31
-rw-r--r--src/view/screens/ProfileList.tsx4
-rw-r--r--src/view/screens/SavedFeeds.tsx293
-rw-r--r--src/view/screens/SearchMobile.tsx2
-rw-r--r--src/view/screens/Settings.tsx31
12 files changed, 1112 insertions, 44 deletions
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx
new file mode 100644
index 000000000..4149cd49d
--- /dev/null
+++ b/src/view/screens/CustomFeed.tsx
@@ -0,0 +1,418 @@
+import React, {useMemo, useRef} from 'react'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {usePalette} from 'lib/hooks/usePalette'
+import {HeartIcon, HeartIconSolid} from 'lib/icons'
+import {CommonNavigatorParams} from 'lib/routes/types'
+import {makeRecordUri} from 'lib/strings/url-helpers'
+import {colors, s} from 'lib/styles'
+import {observer} from 'mobx-react-lite'
+import {FlatList, StyleSheet, View} from 'react-native'
+import {useStores} from 'state/index'
+import {PostsFeedModel} from 'state/models/feeds/posts'
+import {useCustomFeed} from 'lib/hooks/useCustomFeed'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {Feed} from 'view/com/posts/Feed'
+import {pluralize} from 'lib/strings/helpers'
+import {TextLink} from 'view/com/util/Link'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {Button} from 'view/com/util/forms/Button'
+import {Text} from 'view/com/util/text/Text'
+import * as Toast from 'view/com/util/Toast'
+import {isDesktopWeb} from 'platform/detection'
+import {useSetTitle} from 'lib/hooks/useSetTitle'
+import {shareUrl} from 'lib/sharing'
+import {toShareUrl} from 'lib/strings/url-helpers'
+import {Haptics} from 'lib/haptics'
+import {ComposeIcon2} from 'lib/icons'
+import {FAB} from '../com/util/fab/FAB'
+import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
+import {DropdownButton, DropdownItem} from 'view/com/util/forms/DropdownButton'
+import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
+import {EmptyState} from 'view/com/util/EmptyState'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'>
+export const CustomFeedScreen = withAuthRequired(
+  observer(({route}: Props) => {
+    const store = useStores()
+    const pal = usePalette('default')
+    const {rkey, name} = route.params
+    const uri = useMemo(
+      () => makeRecordUri(name, 'app.bsky.feed.generator', rkey),
+      [rkey, name],
+    )
+    const scrollElRef = useRef<FlatList>(null)
+    const currentFeed = useCustomFeed(uri)
+    const algoFeed: PostsFeedModel = useMemo(() => {
+      const feed = new PostsFeedModel(store, 'custom', {
+        feed: uri,
+      })
+      feed.setup()
+      return feed
+    }, [store, uri])
+    const isPinned = store.me.savedFeeds.isPinned(uri)
+    const [onMainScroll, isScrolledDown, resetMainScroll] =
+      useOnMainScroll(store)
+    useSetTitle(currentFeed?.displayName)
+
+    const onToggleSaved = React.useCallback(async () => {
+      try {
+        Haptics.default()
+        if (currentFeed?.isSaved) {
+          await currentFeed?.unsave()
+        } else {
+          await currentFeed?.save()
+        }
+      } catch (err) {
+        Toast.show(
+          'There was an an issue updating your feeds, please check your internet connection and try again.',
+        )
+        store.log.error('Failed up update feeds', {err})
+      }
+    }, [store, currentFeed])
+
+    const onToggleLiked = React.useCallback(async () => {
+      Haptics.default()
+      try {
+        if (currentFeed?.isLiked) {
+          await currentFeed?.unlike()
+        } else {
+          await currentFeed?.like()
+        }
+      } catch (err) {
+        Toast.show(
+          'There was an an issue contacting the server, please check your internet connection and try again.',
+        )
+        store.log.error('Failed up toggle like', {err})
+      }
+    }, [store, currentFeed])
+
+    const onTogglePinned = React.useCallback(async () => {
+      Haptics.default()
+      store.me.savedFeeds.togglePinnedFeed(currentFeed!).catch(e => {
+        Toast.show('There was an issue contacting the server')
+        store.log.error('Failed to toggle pinned feed', {e})
+      })
+    }, [store, currentFeed])
+
+    const onPressShare = React.useCallback(() => {
+      const url = toShareUrl(`/profile/${name}/feed/${rkey}`)
+      shareUrl(url)
+    }, [name, rkey])
+
+    const onScrollToTop = React.useCallback(() => {
+      scrollElRef.current?.scrollToOffset({offset: 0, animated: true})
+      resetMainScroll()
+    }, [scrollElRef, resetMainScroll])
+
+    const onPressCompose = React.useCallback(() => {
+      store.shell.openComposer({})
+    }, [store])
+
+    const dropdownItems: DropdownItem[] = React.useMemo(() => {
+      let items: DropdownItem[] = [
+        {
+          testID: 'feedHeaderDropdownRemoveBtn',
+          label: 'Remove from my feeds',
+          onPress: onToggleSaved,
+        },
+        {
+          testID: 'feedHeaderDropdownShareBtn',
+          label: 'Share link',
+          onPress: onPressShare,
+        },
+      ]
+      return items
+    }, [onToggleSaved, onPressShare])
+
+    const renderHeaderBtns = React.useCallback(() => {
+      return (
+        <View style={styles.headerBtns}>
+          <Button
+            type="default-light"
+            testID="toggleLikeBtn"
+            accessibilityLabel="Like this feed"
+            accessibilityHint=""
+            onPress={onToggleLiked}>
+            {currentFeed?.isLiked ? (
+              <HeartIconSolid size={19} style={styles.liked} />
+            ) : (
+              <HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
+            )}
+          </Button>
+          {currentFeed?.isSaved ? (
+            <Button
+              type="default-light"
+              accessibilityLabel={
+                isPinned ? 'Unpin this feed' : 'Pin this feed'
+              }
+              accessibilityHint=""
+              onPress={onTogglePinned}>
+              <FontAwesomeIcon
+                icon="thumb-tack"
+                size={17}
+                color={isPinned ? colors.blue3 : pal.colors.textLight}
+                style={styles.top1}
+              />
+            </Button>
+          ) : undefined}
+          {currentFeed?.isSaved ? (
+            <DropdownButton
+              testID="feedHeaderDropdownBtn"
+              type="default-light"
+              items={dropdownItems}
+              menuWidth={250}>
+              <FontAwesomeIcon
+                icon="ellipsis"
+                color={pal.colors.textLight}
+                size={18}
+              />
+            </DropdownButton>
+          ) : (
+            <Button
+              type="default-light"
+              onPress={onToggleSaved}
+              accessibilityLabel="Add to my feeds"
+              accessibilityHint=""
+              style={styles.headerAddBtn}>
+              <FontAwesomeIcon icon="plus" color={pal.colors.link} size={19} />
+              <Text type="xl-medium" style={pal.link}>
+                Add to My Feeds
+              </Text>
+            </Button>
+          )}
+        </View>
+      )
+    }, [
+      pal,
+      currentFeed?.isSaved,
+      currentFeed?.isLiked,
+      isPinned,
+      onToggleSaved,
+      onTogglePinned,
+      onToggleLiked,
+      dropdownItems,
+    ])
+
+    const renderListHeaderComponent = React.useCallback(() => {
+      return (
+        <>
+          <View style={[styles.header, pal.border]}>
+            <View style={s.flex1}>
+              <Text
+                testID="feedName"
+                type="title-xl"
+                style={[pal.text, s.bold]}>
+                {currentFeed?.displayName}
+              </Text>
+              {currentFeed && (
+                <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+                  by{' '}
+                  {currentFeed.data.creator.did === store.me.did ? (
+                    'you'
+                  ) : (
+                    <TextLink
+                      text={`@${currentFeed.data.creator.handle}`}
+                      href={`/profile/${currentFeed.data.creator.did}`}
+                      style={[pal.textLight]}
+                    />
+                  )}
+                </Text>
+              )}
+              {isDesktopWeb && (
+                <View style={[styles.headerBtns, styles.headerBtnsDesktop]}>
+                  <Button
+                    type={currentFeed?.isSaved ? 'default' : 'inverted'}
+                    onPress={onToggleSaved}
+                    accessibilityLabel={
+                      currentFeed?.isSaved
+                        ? 'Unsave this feed'
+                        : 'Save this feed'
+                    }
+                    accessibilityHint=""
+                    label={
+                      currentFeed?.isSaved
+                        ? 'Remove from My Feeds'
+                        : 'Add to My Feeds'
+                    }
+                  />
+                  <Button
+                    type="default"
+                    accessibilityLabel={
+                      isPinned ? 'Unpin this feed' : 'Pin this feed'
+                    }
+                    accessibilityHint=""
+                    onPress={onTogglePinned}>
+                    <FontAwesomeIcon
+                      icon="thumb-tack"
+                      size={15}
+                      color={isPinned ? colors.blue3 : pal.colors.icon}
+                      style={styles.top2}
+                    />
+                  </Button>
+                  <Button
+                    type="default"
+                    accessibilityLabel="Like this feed"
+                    accessibilityHint=""
+                    onPress={onToggleLiked}>
+                    {currentFeed?.isLiked ? (
+                      <HeartIconSolid size={18} style={styles.liked} />
+                    ) : (
+                      <HeartIcon strokeWidth={3} size={18} style={pal.icon} />
+                    )}
+                  </Button>
+                  <Button
+                    type="default"
+                    accessibilityLabel="Share this feed"
+                    accessibilityHint=""
+                    onPress={onPressShare}>
+                    <FontAwesomeIcon
+                      icon="share"
+                      size={18}
+                      color={pal.colors.icon}
+                    />
+                  </Button>
+                </View>
+              )}
+            </View>
+            <View>
+              <UserAvatar
+                type="algo"
+                avatar={currentFeed?.data.avatar}
+                size={64}
+              />
+            </View>
+          </View>
+          <View style={styles.headerDetails}>
+            {currentFeed?.data.description ? (
+              <Text style={[pal.text, s.mb10]} numberOfLines={6}>
+                {currentFeed.data.description}
+              </Text>
+            ) : null}
+            <View style={styles.headerDetailsFooter}>
+              {currentFeed ? (
+                <TextLink
+                  type="md-medium"
+                  style={pal.textLight}
+                  href={`/profile/${name}/feed/${rkey}/liked-by`}
+                  text={`Liked by ${currentFeed.data.likeCount} ${pluralize(
+                    currentFeed?.data.likeCount || 0,
+                    'user',
+                  )}`}
+                />
+              ) : null}
+            </View>
+          </View>
+          <View style={[styles.fakeSelector, pal.border]}>
+            <View
+              style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}>
+              <Text type="md-medium" style={[pal.text]}>
+                Feed
+              </Text>
+            </View>
+          </View>
+        </>
+      )
+    }, [
+      pal,
+      currentFeed,
+      store.me.did,
+      onToggleSaved,
+      onToggleLiked,
+      onPressShare,
+      name,
+      rkey,
+      isPinned,
+      onTogglePinned,
+    ])
+
+    const renderEmptyState = React.useCallback(() => {
+      return <EmptyState icon="feed" message="This list is empty!" />
+    }, [])
+
+    return (
+      <View style={s.hContentRegion}>
+        <ViewHeader title="" renderButton={currentFeed && renderHeaderBtns} />
+        <Feed
+          scrollElRef={scrollElRef}
+          feed={algoFeed}
+          onScroll={onMainScroll}
+          scrollEventThrottle={100}
+          ListHeaderComponent={renderListHeaderComponent}
+          renderEmptyState={renderEmptyState}
+          extraData={[uri, isPinned]}
+        />
+        {isScrolledDown ? (
+          <LoadLatestBtn
+            onPress={onScrollToTop}
+            label="Scroll to top"
+            showIndicator={false}
+          />
+        ) : null}
+        <FAB
+          testID="composeFAB"
+          onPress={onPressCompose}
+          icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
+          accessibilityRole="button"
+          accessibilityLabel="Compose post"
+          accessibilityHint=""
+        />
+      </View>
+    )
+  }),
+)
+
+const styles = StyleSheet.create({
+  header: {
+    flexDirection: 'row',
+    gap: 12,
+    paddingHorizontal: 16,
+    paddingTop: 12,
+    paddingBottom: 16,
+    borderTopWidth: 1,
+  },
+  headerBtns: {
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  headerBtnsDesktop: {
+    marginTop: 8,
+    gap: 4,
+  },
+  headerAddBtn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 4,
+    paddingLeft: 4,
+  },
+  headerDetails: {
+    paddingHorizontal: 16,
+    paddingBottom: 16,
+  },
+  headerDetailsFooter: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'space-between',
+  },
+  fakeSelector: {
+    flexDirection: 'row',
+    paddingHorizontal: isDesktopWeb ? 16 : 6,
+  },
+  fakeSelectorItem: {
+    paddingHorizontal: 12,
+    paddingBottom: 8,
+    borderBottomWidth: 3,
+  },
+  liked: {
+    color: colors.red3,
+  },
+  top1: {
+    position: 'relative',
+    top: 1,
+  },
+  top2: {
+    position: 'relative',
+    top: 2,
+  },
+})
diff --git a/src/view/screens/CustomFeedLikedBy.tsx b/src/view/screens/CustomFeedLikedBy.tsx
new file mode 100644
index 000000000..49d0d0482
--- /dev/null
+++ b/src/view/screens/CustomFeedLikedBy.tsx
@@ -0,0 +1,29 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {ViewHeader} from '../com/util/ViewHeader'
+import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
+import {useStores} from 'state/index'
+import {makeRecordUri} from 'lib/strings/url-helpers'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeedLikedBy'>
+export const CustomFeedLikedByScreen = withAuthRequired(({route}: Props) => {
+  const store = useStores()
+  const {name, rkey} = route.params
+  const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey)
+
+  useFocusEffect(
+    React.useCallback(() => {
+      store.shell.setMinimalShellMode(false)
+    }, [store]),
+  )
+
+  return (
+    <View>
+      <ViewHeader title="Liked by" />
+      <PostLikedByComponent uri={uri} />
+    </View>
+  )
+})
diff --git a/src/view/screens/DiscoverFeeds.tsx b/src/view/screens/DiscoverFeeds.tsx
new file mode 100644
index 000000000..cd32ec655
--- /dev/null
+++ b/src/view/screens/DiscoverFeeds.tsx
@@ -0,0 +1,112 @@
+import React from 'react'
+import {RefreshControl, StyleSheet, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {ViewHeader} from '../com/util/ViewHeader'
+import {useStores} from 'state/index'
+import {FeedsDiscoveryModel} from 'state/models/discovery/feeds'
+import {CenteredView, FlatList} from 'view/com/util/Views'
+import {CustomFeed} from 'view/com/feeds/CustomFeed'
+import {Text} from 'view/com/util/text/Text'
+import {isDesktopWeb} from 'platform/detection'
+import {usePalette} from 'lib/hooks/usePalette'
+import {s} from 'lib/styles'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'DiscoverFeeds'>
+export const DiscoverFeedsScreen = withAuthRequired(
+  observer(({}: Props) => {
+    const store = useStores()
+    const pal = usePalette('default')
+    const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store])
+
+    useFocusEffect(
+      React.useCallback(() => {
+        store.shell.setMinimalShellMode(false)
+        feeds.refresh()
+      }, [store, feeds]),
+    )
+
+    const onRefresh = React.useCallback(() => {
+      store.me.savedFeeds.refresh()
+    }, [store])
+
+    const renderListEmptyComponent = React.useCallback(() => {
+      return (
+        <View
+          style={[
+            pal.border,
+            !isDesktopWeb && s.flex1,
+            pal.viewLight,
+            styles.empty,
+          ]}>
+          <Text type="lg" style={[pal.text]}>
+            {feeds.isLoading
+              ? 'Loading...'
+              : `We can't find any feeds for some reason. This is probably an error - try refreshing!`}
+          </Text>
+        </View>
+      )
+    }, [pal, feeds.isLoading])
+
+    const renderItem = React.useCallback(
+      ({item}) => (
+        <CustomFeed
+          key={item.data.uri}
+          item={item}
+          showSaveBtn
+          showDescription
+          showLikes
+        />
+      ),
+      [],
+    )
+
+    return (
+      <CenteredView style={[styles.container, pal.view]}>
+        <View style={[isDesktopWeb && styles.containerDesktop, pal.border]}>
+          <ViewHeader title="Discover Feeds" showOnDesktop />
+        </View>
+        <FlatList
+          style={[!isDesktopWeb && s.flex1]}
+          data={feeds.feeds}
+          keyExtractor={item => item.data.uri}
+          contentContainerStyle={styles.contentContainer}
+          refreshControl={
+            <RefreshControl
+              refreshing={feeds.isRefreshing}
+              onRefresh={onRefresh}
+              tintColor={pal.colors.text}
+              titleColor={pal.colors.text}
+            />
+          }
+          renderItem={renderItem}
+          initialNumToRender={10}
+          ListEmptyComponent={renderListEmptyComponent}
+          extraData={feeds.isLoading}
+        />
+      </CenteredView>
+    )
+  }),
+)
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+  },
+  contentContainer: {
+    paddingBottom: 100,
+  },
+  containerDesktop: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
+  empty: {
+    paddingHorizontal: 18,
+    paddingVertical: 16,
+    borderRadius: 8,
+    marginHorizontal: 18,
+    marginTop: 10,
+  },
+})
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
new file mode 100644
index 000000000..169f88791
--- /dev/null
+++ b/src/view/screens/Feeds.tsx
@@ -0,0 +1,126 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import isEqual from 'lodash.isequal'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {FlatList} from 'view/com/util/Views'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
+import {FAB} from 'view/com/util/fab/FAB'
+import {Link} from 'view/com/util/Link'
+import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types'
+import {observer} from 'mobx-react-lite'
+import {PostsMultiFeedModel} from 'state/models/feeds/multi-feed'
+import {MultiFeed} from 'view/com/posts/MultiFeed'
+import {isDesktopWeb} from 'platform/detection'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
+import {ComposeIcon2, CogIcon} from 'lib/icons'
+import {s} from 'lib/styles'
+
+const HEADER_OFFSET = isDesktopWeb ? 0 : 40
+
+type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
+export const FeedsScreen = withAuthRequired(
+  observer<Props>(({}: Props) => {
+    const pal = usePalette('default')
+    const store = useStores()
+    const flatListRef = React.useRef<FlatList>(null)
+    const multifeed = React.useMemo<PostsMultiFeedModel>(
+      () => new PostsMultiFeedModel(store),
+      [store],
+    )
+    const [onMainScroll, isScrolledDown, resetMainScroll] =
+      useOnMainScroll(store)
+
+    const onSoftReset = React.useCallback(() => {
+      flatListRef.current?.scrollToOffset({offset: 0})
+      resetMainScroll()
+    }, [flatListRef, resetMainScroll])
+
+    useFocusEffect(
+      React.useCallback(() => {
+        const softResetSub = store.onScreenSoftReset(onSoftReset)
+        const multifeedCleanup = multifeed.registerListeners()
+        const cleanup = () => {
+          softResetSub.remove()
+          multifeedCleanup()
+        }
+
+        store.shell.setMinimalShellMode(false)
+        return cleanup
+      }, [store, multifeed, onSoftReset]),
+    )
+
+    React.useEffect(() => {
+      if (
+        isEqual(
+          multifeed.feedInfos.map(f => f.uri),
+          store.me.savedFeeds.all.map(f => f.uri),
+        )
+      ) {
+        // no changes
+        return
+      }
+      multifeed.refresh()
+    }, [multifeed, store.me.savedFeeds.all])
+
+    const onPressCompose = React.useCallback(() => {
+      store.shell.openComposer({})
+    }, [store])
+
+    const renderHeaderBtn = React.useCallback(() => {
+      return (
+        <Link
+          href="/settings/saved-feeds"
+          hitSlop={10}
+          accessibilityRole="button"
+          accessibilityLabel="Edit Saved Feeds"
+          accessibilityHint="Opens screen to edit Saved Feeds">
+          <CogIcon size={22} strokeWidth={2} style={pal.textLight} />
+        </Link>
+      )
+    }, [pal])
+
+    return (
+      <View style={[pal.view, styles.container]}>
+        <MultiFeed
+          scrollElRef={flatListRef}
+          multifeed={multifeed}
+          onScroll={onMainScroll}
+          scrollEventThrottle={100}
+          headerOffset={HEADER_OFFSET}
+          showPostFollowBtn
+        />
+        <ViewHeader
+          title="My Feeds"
+          canGoBack={false}
+          hideOnScroll
+          renderButton={renderHeaderBtn}
+        />
+        {isScrolledDown ? (
+          <LoadLatestBtn
+            onPress={onSoftReset}
+            label="Scroll to top"
+            showIndicator={false}
+          />
+        ) : null}
+        <FAB
+          testID="composeFAB"
+          onPress={onPressCompose}
+          icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
+          accessibilityRole="button"
+          accessibilityLabel="Compose post"
+          accessibilityHint=""
+        />
+      </View>
+    )
+  }),
+)
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+  },
+})
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 0ead6b65c..eae4d9799 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,18 +1,20 @@
 import React from 'react'
 import {FlatList, View} from 'react-native'
 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'
 import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect'
 import {Feed} from '../com/posts/Feed'
 import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
-import {WhatsHotEmptyState} from 'view/com/posts/WhatsHotEmptyState'
+import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
 import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn'
 import {FeedsTabBar} from '../com/pager/FeedsTabBar'
-import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager'
+import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
 import {FAB} from '../com/util/fab/FAB'
 import {useStores} from 'state/index'
 import {s} from 'lib/styles'
@@ -21,30 +23,37 @@ import {useAnalytics} from 'lib/analytics'
 import {ComposeIcon2} from 'lib/icons'
 import {isDesktopWeb} from 'platform/detection'
 
-const HEADER_OFFSET = isDesktopWeb ? 50 : 40
+const HEADER_OFFSET = isDesktopWeb ? 50 : 78
 const POLL_FREQ = 30e3 // 30sec
 
 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
 export const HomeScreen = withAuthRequired(
   observer((_opts: Props) => {
     const store = useStores()
+    const pagerRef = React.useRef<PagerRef>(null)
     const [selectedPage, setSelectedPage] = React.useState(0)
-    const [initialLanguages] = React.useState(
-      store.preferences.contentLanguages,
-    )
-
-    const algoFeed: PostsFeedModel = React.useMemo(() => {
-      const feed = new PostsFeedModel(store, 'goodstuff', {})
-      feed.setup()
-      return feed
-    }, [store])
+    const [customFeeds, setCustomFeeds] = React.useState<PostsFeedModel[]>([])
 
     React.useEffect(() => {
-      // refresh whats hot when lang preferences change
-      if (initialLanguages !== store.preferences.contentLanguages) {
-        algoFeed.refresh()
+      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 pinned) {
+        const model = new PostsFeedModel(store, 'custom', {feed: feed.uri})
+        model.setup()
+        feeds.push(model)
       }
-    }, [initialLanguages, store.preferences.contentLanguages, algoFeed])
+      setCustomFeeds(feeds)
+    }, [store, store.me.savedFeeds.pinned, customFeeds, setCustomFeeds])
 
     useFocusEffect(
       React.useCallback(() => {
@@ -86,18 +95,17 @@ export const HomeScreen = withAuthRequired(
       return <FollowingEmptyState />
     }, [])
 
-    const renderWhatsHotEmptyState = React.useCallback(() => {
-      return <WhatsHotEmptyState />
+    const renderCustomFeedEmptyState = React.useCallback(() => {
+      return <CustomFeedEmptyState />
     }, [])
 
-    const initialPage = store.me.followsCount === 0 ? 1 : 0
     return (
       <Pager
+        ref={pagerRef}
         testID="homeScreen"
         onPageSelected={onPageSelected}
         renderTabBar={renderTabBar}
-        tabBarPosition="top"
-        initialPage={initialPage}>
+        tabBarPosition="top">
         <FeedPage
           key="1"
           testID="followingFeedPage"
@@ -105,13 +113,17 @@ export const HomeScreen = withAuthRequired(
           feed={store.me.mainFeed}
           renderEmptyState={renderFollowingEmptyState}
         />
-        <FeedPage
-          key="2"
-          testID="whatshotFeedPage"
-          isPageFocused={selectedPage === 1}
-          feed={algoFeed}
-          renderEmptyState={renderWhatsHotEmptyState}
-        />
+        {customFeeds.map((f, index) => {
+          return (
+            <FeedPage
+              key={(f.params as GetCustomFeed.QueryParams).feed}
+              testID="customFeedPage"
+              isPageFocused={selectedPage === 1 + index}
+              feed={f}
+              renderEmptyState={renderCustomFeedEmptyState}
+            />
+          )
+        })}
       </Pager>
     )
   }),
@@ -130,7 +142,8 @@ const FeedPage = observer(
     renderEmptyState?: () => JSX.Element
   }) => {
     const store = useStores()
-    const onMainScroll = useOnMainScroll(store)
+    const [onMainScroll, isScrolledDown, resetMainScroll] =
+      useOnMainScroll(store)
     const {screen, track} = useAnalytics()
     const scrollElRef = React.useRef<FlatList>(null)
     const {appState} = useAppState({
@@ -158,12 +171,13 @@ const FeedPage = observer(
 
     const scrollToTop = React.useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: -HEADER_OFFSET})
-    }, [scrollElRef])
+      resetMainScroll()
+    }, [scrollElRef, resetMainScroll])
 
     const onSoftReset = React.useCallback(() => {
       if (isPageFocused) {
-        feed.refresh()
         scrollToTop()
+        feed.refresh()
       }
     }, [isPageFocused, scrollToTop, feed])
 
@@ -224,6 +238,7 @@ const FeedPage = observer(
       feed.refresh()
     }, [feed, scrollToTop])
 
+    const hasNew = feed.hasNewLatest && !feed.isRefreshing
     return (
       <View testID={testID} style={s.h100pct}>
         <Feed
@@ -234,11 +249,17 @@ const FeedPage = observer(
           showPostFollowBtn
           onPressTryAgain={onPressTryAgain}
           onScroll={onMainScroll}
+          scrollEventThrottle={100}
           renderEmptyState={renderEmptyState}
           headerOffset={HEADER_OFFSET}
         />
-        {feed.hasNewLatest && !feed.isRefreshing && (
-          <LoadLatestBtn onPress={onPressLoadLatest} label="posts" />
+        {(isScrolledDown || hasNew) && (
+          <LoadLatestBtn
+            onPress={onPressLoadLatest}
+            label="Load new posts"
+            showIndicator={hasNew}
+            minimalShellMode={store.shell.minimalShellMode}
+          />
         )}
         <FAB
           testID="composeFAB"
diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx
index ec732f682..22b8c0d33 100644
--- a/src/view/screens/ModerationMutedAccounts.tsx
+++ b/src/view/screens/ModerationMutedAccounts.tsx
@@ -100,7 +100,7 @@ export const ModerationMutedAccounts = withAuthRequired(
           <FlatList
             style={[!isDesktopWeb && styles.flex1]}
             data={mutedAccounts.mutes}
-            keyExtractor={(item: ActorDefs.ProfileView) => item.did}
+            keyExtractor={item => item.did}
             refreshControl={
               <RefreshControl
                 refreshing={mutedAccounts.isRefreshing}
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index 8d6f7c83a..4db1d14ae 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -25,7 +25,8 @@ type Props = NativeStackScreenProps<
 export const NotificationsScreen = withAuthRequired(
   observer(({}: Props) => {
     const store = useStores()
-    const onMainScroll = useOnMainScroll(store)
+    const [onMainScroll, isScrolledDown, resetMainScroll] =
+      useOnMainScroll(store)
     const scrollElRef = React.useRef<FlatList>(null)
     const {screen} = useAnalytics()
 
@@ -37,7 +38,8 @@ export const NotificationsScreen = withAuthRequired(
 
     const scrollToTop = React.useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: 0})
-    }, [scrollElRef])
+      resetMainScroll()
+    }, [scrollElRef, resetMainScroll])
 
     const onPressLoadLatest = React.useCallback(() => {
       scrollToTop()
@@ -86,6 +88,9 @@ export const NotificationsScreen = withAuthRequired(
       ),
     )
 
+    const hasNew =
+      store.me.notifications.hasNewLatest &&
+      !store.me.notifications.isRefreshing
     return (
       <View testID="notificationsScreen" style={s.hContentRegion}>
         <ViewHeader title="Notifications" canGoBack={false} />
@@ -96,10 +101,14 @@ export const NotificationsScreen = withAuthRequired(
           onScroll={onMainScroll}
           scrollElRef={scrollElRef}
         />
-        {store.me.notifications.hasNewLatest &&
-          !store.me.notifications.isRefreshing && (
-            <LoadLatestBtn onPress={onPressLoadLatest} label="notifications" />
-          )}
+        {(isScrolledDown || hasNew) && (
+          <LoadLatestBtn
+            onPress={onPressLoadLatest}
+            label="Load new notifications"
+            showIndicator={hasNew}
+            minimalShellMode={true}
+          />
+        )}
       </View>
     )
   }),
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index b6d92e46b..77e3743e5 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -9,7 +9,7 @@ import {CenteredView} from '../com/util/Views'
 import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
 import {ProfileUiModel, Sections} from 'state/models/ui/profile'
 import {useStores} from 'state/index'
-import {PostsFeedSliceModel} from 'state/models/feeds/posts'
+import {PostsFeedSliceModel} from 'state/models/feeds/post'
 import {ProfileHeader} from '../com/profile/ProfileHeader'
 import {FeedSlice} from '../com/posts/FeedSlice'
 import {ListCard} from 'view/com/lists/ListCard'
@@ -25,6 +25,8 @@ import {FAB} from '../com/util/fab/FAB'
 import {s, colors} from 'lib/styles'
 import {useAnalytics} from 'lib/analytics'
 import {ComposeIcon2} from 'lib/icons'
+import {CustomFeed} from 'view/com/feeds/CustomFeed'
+import {CustomFeedModel} from 'state/models/feeds/custom-feed'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {combinedDisplayName} from 'lib/strings/display-names'
 
@@ -118,6 +120,7 @@ export const ProfileScreen = withAuthRequired(
     }, [uiState.showLoadingMoreFooter])
     const renderItem = React.useCallback(
       (item: any) => {
+        // if section is lists
         if (uiState.selectedView === Sections.Lists) {
           if (item === ProfileUiModel.LOADING_ITEM) {
             return <ProfileCardFeedLoadingPlaceholder />
@@ -142,6 +145,32 @@ export const ProfileScreen = withAuthRequired(
           } else {
             return <ListCard testID={`list-${item.name}`} list={item} />
           }
+          // if section is custom algorithms
+        } else if (uiState.selectedView === Sections.CustomAlgorithms) {
+          if (item === ProfileUiModel.LOADING_ITEM) {
+            return <ProfileCardFeedLoadingPlaceholder />
+          } else if (item._reactKey === '__error__') {
+            return (
+              <View style={s.p5}>
+                <ErrorMessage
+                  message={item.error}
+                  onPressTryAgain={onPressTryAgain}
+                />
+              </View>
+            )
+          } else if (item === ProfileUiModel.EMPTY_ITEM) {
+            return (
+              <EmptyState
+                testID="customAlgorithmsEmpty"
+                icon="list-ul"
+                message="No custom algorithms yet!"
+                style={styles.emptyState}
+              />
+            )
+          } else if (item instanceof CustomFeedModel) {
+            return <CustomFeed item={item} showSaveBtn showLikes />
+          }
+          // if section is posts or posts & replies
         } else {
           if (item === ProfileUiModel.END_ITEM) {
             return <Text style={styles.endItem}>- end of feed -</Text>
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 01f27bae1..7c3ed831c 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -87,7 +87,7 @@ export const ProfileListScreen = withAuthRequired(
       return <EmptyState icon="users-slash" message="This list is empty!" />
     }, [])
 
-    const renderHeaderBtn = React.useCallback(() => {
+    const renderHeaderBtns = React.useCallback(() => {
       return (
         <View style={styles.headerBtns}>
           {list?.isOwner && (
@@ -148,7 +148,7 @@ export const ProfileListScreen = withAuthRequired(
           pal.border,
         ]}
         testID="moderationMutelistsScreen">
-        <ViewHeader title="" renderButton={renderHeaderBtn} />
+        <ViewHeader title="" renderButton={renderHeaderBtns} />
         <ListItems
           list={list}
           renderEmptyState={renderEmptyState}
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
new file mode 100644
index 000000000..103b18c70
--- /dev/null
+++ b/src/view/screens/SavedFeeds.tsx
@@ -0,0 +1,293 @@
+import React, {useCallback, useMemo} from 'react'
+import {
+  RefreshControl,
+  StyleSheet,
+  View,
+  ActivityIndicator,
+  Pressable,
+  TouchableOpacity,
+} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {useAnalytics} from 'lib/analytics'
+import {usePalette} from 'lib/hooks/usePalette'
+import {CommonNavigatorParams} from 'lib/routes/types'
+import {observer} from 'mobx-react-lite'
+import {useStores} from 'state/index'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {CenteredView} from 'view/com/util/Views'
+import {Text} from 'view/com/util/text/Text'
+import {isDesktopWeb, isWeb} from 'platform/detection'
+import {s, colors} from 'lib/styles'
+import DraggableFlatList, {
+  ShadowDecorator,
+  ScaleDecorator,
+} from 'react-native-draggable-flatlist'
+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'
+import {Haptics} from 'lib/haptics'
+import {Link, TextLink} from 'view/com/util/Link'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
+
+export const SavedFeeds = withAuthRequired(
+  observer(({}: Props) => {
+    const pal = usePalette('default')
+    const store = useStores()
+    const {screen} = useAnalytics()
+
+    const savedFeeds = useMemo(() => store.me.savedFeeds, [store])
+    useFocusEffect(
+      useCallback(() => {
+        screen('SavedFeeds')
+        store.shell.setMinimalShellMode(false)
+        savedFeeds.refresh()
+      }, [screen, store, savedFeeds]),
+    )
+
+    const renderListEmptyComponent = useCallback(() => {
+      return (
+        <View
+          style={[
+            pal.border,
+            !isDesktopWeb && s.flex1,
+            pal.viewLight,
+            styles.empty,
+          ]}>
+          <Text type="lg" style={[pal.text]}>
+            You don't have any saved feeds.
+          </Text>
+        </View>
+      )
+    }, [pal])
+
+    const renderListFooterComponent = useCallback(() => {
+      return (
+        <>
+          <View style={[styles.footerLinks, pal.border]}>
+            <Link style={styles.footerLink} href="/search/feeds">
+              <FontAwesomeIcon
+                icon="search"
+                size={18}
+                color={pal.colors.icon}
+              />
+              <Text type="lg-medium" style={pal.textLight}>
+                Discover new feeds
+              </Text>
+            </Link>
+          </View>
+          <View style={styles.footerText}>
+            <Text type="sm" style={pal.textLight}>
+              Feeds are custom algorithms that users build with a little coding
+              expertise.{' '}
+              <TextLink
+                type="sm"
+                style={pal.link}
+                href="https://github.com/bluesky-social/feed-generator"
+                text="See this guide"
+              />{' '}
+              for more information.
+            </Text>
+          </View>
+          {savedFeeds.isLoading && <ActivityIndicator />}
+        </>
+      )
+    }, [pal, savedFeeds.isLoading])
+
+    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
+        style={[
+          s.hContentRegion,
+          pal.border,
+          isDesktopWeb && styles.desktopContainer,
+        ]}>
+        <ViewHeader
+          title="Edit My Feeds"
+          showOnDesktop
+          showBorder={!isDesktopWeb}
+        />
+        <DraggableFlatList
+          containerStyle={[!isDesktopWeb && s.flex1]}
+          data={savedFeeds.all}
+          keyExtractor={item => item.data.uri}
+          refreshing={savedFeeds.isRefreshing}
+          refreshControl={
+            <RefreshControl
+              refreshing={savedFeeds.isRefreshing}
+              onRefresh={onRefresh}
+              tintColor={pal.colors.text}
+              titleColor={pal.colors.text}
+            />
+          }
+          renderItem={({item, drag}) => <ListItem item={item} drag={drag} />}
+          getItemLayout={(data, index) => ({
+            length: 77,
+            offset: 77 * index,
+            index,
+          })}
+          initialNumToRender={10}
+          ListFooterComponent={renderListFooterComponent}
+          ListEmptyComponent={renderListEmptyComponent}
+          extraData={savedFeeds.isLoading}
+          onDragEnd={onDragEnd}
+        />
+      </CenteredView>
+    )
+  }),
+)
+
+const ListItem = observer(
+  ({item, drag}: {item: CustomFeedModel; drag: () => void}) => {
+    const pal = usePalette('default')
+    const store = useStores()
+    const savedFeeds = useMemo(() => store.me.savedFeeds, [store])
+    const isPinned = savedFeeds.isPinned(item)
+
+    const onTogglePinned = useCallback(() => {
+      Haptics.default()
+      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>
+          <Pressable
+            accessibilityRole="button"
+            onLongPress={isPinned ? drag : undefined}
+            delayLongPress={200}
+            style={[styles.itemContainer, pal.border]}>
+            {isPinned && isWeb ? (
+              <View style={styles.webArrowButtonsContainer}>
+                <TouchableOpacity
+                  accessibilityRole="button"
+                  onPress={onPressUp}>
+                  <FontAwesomeIcon
+                    icon="arrow-up"
+                    size={12}
+                    style={[pal.text, styles.webArrowUpButton]}
+                  />
+                </TouchableOpacity>
+                <TouchableOpacity
+                  accessibilityRole="button"
+                  onPress={onPressDown}>
+                  <FontAwesomeIcon
+                    icon="arrow-down"
+                    size={12}
+                    style={[pal.text]}
+                  />
+                </TouchableOpacity>
+              </View>
+            ) : isPinned ? (
+              <FontAwesomeIcon
+                icon="bars"
+                size={20}
+                color={pal.colors.text}
+                style={s.ml20}
+              />
+            ) : null}
+            <CustomFeed
+              key={item.data.uri}
+              item={item}
+              showSaveBtn
+              style={styles.noBorder}
+            />
+            <TouchableOpacity
+              accessibilityRole="button"
+              hitSlop={10}
+              onPress={onTogglePinned}>
+              <FontAwesomeIcon
+                icon="thumb-tack"
+                size={20}
+                color={isPinned ? colors.blue3 : pal.colors.icon}
+              />
+            </TouchableOpacity>
+          </Pressable>
+        </ShadowDecorator>
+      </ScaleDecorator>
+    )
+  },
+)
+
+const styles = StyleSheet.create({
+  desktopContainer: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+    minHeight: '100vh',
+  },
+  empty: {
+    paddingHorizontal: 20,
+    paddingVertical: 20,
+    borderRadius: 16,
+    marginHorizontal: 24,
+    marginTop: 10,
+  },
+  itemContainer: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+    borderBottomWidth: 1,
+    paddingRight: 16,
+  },
+  webArrowButtonsContainer: {
+    paddingLeft: 16,
+    flexDirection: 'column',
+    justifyContent: 'space-around',
+  },
+  webArrowUpButton: {
+    marginBottom: 10,
+  },
+  noBorder: {
+    borderTopWidth: 0,
+  },
+  footerText: {
+    paddingHorizontal: 26,
+    paddingTop: 22,
+    paddingBottom: 100,
+  },
+  footerLinks: {
+    borderBottomWidth: 1,
+    borderTopWidth: 0,
+  },
+  footerLink: {
+    flexDirection: 'row',
+    paddingHorizontal: 26,
+    paddingVertical: 18,
+    gap: 18,
+  },
+})
diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx
index f9b4864b2..c9d09373e 100644
--- a/src/view/screens/SearchMobile.tsx
+++ b/src/view/screens/SearchMobile.tsx
@@ -35,7 +35,7 @@ export const SearchScreen = withAuthRequired(
     const store = useStores()
     const scrollViewRef = React.useRef<ScrollView>(null)
     const flatListRef = React.useRef<FlatList>(null)
-    const onMainScroll = useOnMainScroll(store)
+    const [onMainScroll] = useOnMainScroll(store)
     const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
     const [query, setQuery] = React.useState<string>('')
     const autocompleteView = React.useMemo<UserAutocompleteModel>(
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 1571a6142..ac4e5a9e0 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -141,6 +141,11 @@ export const SettingsScreen = withAuthRequired(
       store.shell.openModal({name: 'delete-account'})
     }, [store])
 
+    const onPressResetPreferences = React.useCallback(async () => {
+      await store.preferences.reset()
+      Toast.show('Preferences reset')
+    }, [store])
+
     return (
       <View style={[s.hContentRegion]} testID="settingsScreen">
         <ViewHeader title="Settings" />
@@ -301,6 +306,22 @@ export const SettingsScreen = withAuthRequired(
               App passwords
             </Text>
           </Link>
+          <Link
+            testID="savedFeedsBtn"
+            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
+            accessibilityHint="Saved Feeds"
+            accessibilityLabel="Opens screen with all saved feeds"
+            href="/settings/saved-feeds">
+            <View style={[styles.iconContainer, pal.btn]}>
+              <FontAwesomeIcon
+                icon="satellite-dish"
+                style={pal.text as FontAwesomeIconStyle}
+              />
+            </View>
+            <Text type="lg" style={pal.text}>
+              Saved Feeds
+            </Text>
+          </Link>
           <TouchableOpacity
             testID="contentLanguagesBtn"
             style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
@@ -377,6 +398,16 @@ export const SettingsScreen = withAuthRequired(
               Storybook
             </Text>
           </Link>
+          {__DEV__ ? (
+            <Link
+              style={[pal.view, styles.linkCardNoIcon]}
+              onPress={onPressResetPreferences}
+              title="Debug tools">
+              <Text type="lg" style={pal.text}>
+                Reset preferences state
+              </Text>
+            </Link>
+          ) : null}
           <Text type="sm" style={[styles.buildInfo, pal.textLight]}>
             Build version {AppInfo.appVersion} ({AppInfo.buildVersion})
           </Text>