From 370d52bd1f4ba3b5effb7a48cd1c8b14aea88781 Mon Sep 17 00:00:00 2001
From: Ansh Nanda <anshnanda10@gmail.com>
Date: Fri, 12 May 2023 14:40:58 -0700
Subject: add custom algorithm screen to settings under moderation

---
 src/Navigation.tsx | 2 ++
 1 file changed, 2 insertions(+)

(limited to 'src/Navigation.tsx')

diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index afc7b39b8..8b6e1b453 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -52,6 +52,7 @@ import {AppPasswords} from 'view/screens/AppPasswords'
 import {MutedAccounts} from 'view/screens/MutedAccounts'
 import {BlockedAccounts} from 'view/screens/BlockedAccounts'
 import {getRoutingInstrumentation} from 'lib/sentry'
+import CustomAlgorithms from 'view/screens/CustomAlgorithms'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
 
@@ -91,6 +92,7 @@ function commonScreens(Stack: typeof HomeTab) {
       />
       <Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
       <Stack.Screen name="AppPasswords" component={AppPasswords} />
+      <Stack.Screen name="CustomAlgorithms" component={CustomAlgorithms} />
       <Stack.Screen name="MutedAccounts" component={MutedAccounts} />
       <Stack.Screen name="BlockedAccounts" component={BlockedAccounts} />
     </>
-- 
cgit 1.4.1


From 61ea37ff818ca64cec8e10b3a423ecd22dcf0141 Mon Sep 17 00:00:00 2001
From: Ansh Nanda <anshnanda10@gmail.com>
Date: Sun, 14 May 2023 18:37:18 -0700
Subject: renamed page to savedfeeds

---
 src/Navigation.tsx                    |   4 +-
 src/lib/routes/types.ts               |   2 +-
 src/routes.ts                         |   2 +-
 src/view/screens/CustomAlgorithms.tsx | 105 ----------------------------------
 src/view/screens/SavedFeeds.tsx       | 103 +++++++++++++++++++++++++++++++++
 5 files changed, 107 insertions(+), 109 deletions(-)
 delete mode 100644 src/view/screens/CustomAlgorithms.tsx
 create mode 100644 src/view/screens/SavedFeeds.tsx

(limited to 'src/Navigation.tsx')

diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 8b6e1b453..26dc9f7ad 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -52,7 +52,7 @@ import {AppPasswords} from 'view/screens/AppPasswords'
 import {MutedAccounts} from 'view/screens/MutedAccounts'
 import {BlockedAccounts} from 'view/screens/BlockedAccounts'
 import {getRoutingInstrumentation} from 'lib/sentry'
-import CustomAlgorithms from 'view/screens/CustomAlgorithms'
+import {SavedFeeds} from './view/screens/SavedFeeds'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
 
@@ -92,7 +92,7 @@ function commonScreens(Stack: typeof HomeTab) {
       />
       <Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
       <Stack.Screen name="AppPasswords" component={AppPasswords} />
-      <Stack.Screen name="CustomAlgorithms" component={CustomAlgorithms} />
+      <Stack.Screen name="SavedFeeds" component={SavedFeeds} />
       <Stack.Screen name="MutedAccounts" component={MutedAccounts} />
       <Stack.Screen name="BlockedAccounts" component={BlockedAccounts} />
     </>
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index b91495640..5a4126f6a 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -20,7 +20,7 @@ export type CommonNavigatorParams = {
   CommunityGuidelines: undefined
   CopyrightPolicy: undefined
   AppPasswords: undefined
-  CustomAlgorithms: undefined
+  SavedFeeds: undefined
   MutedAccounts: undefined
   BlockedAccounts: undefined
 }
diff --git a/src/routes.ts b/src/routes.ts
index c1b441984..b510f66f6 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -14,7 +14,7 @@ export const router = new Router({
   Debug: '/sys/debug',
   Log: '/sys/log',
   AppPasswords: '/settings/app-passwords',
-  CustomAlgorithms: '/settings/custom-algorithms',
+  SavedFeeds: '/settings/saved-feeds',
   MutedAccounts: '/settings/muted-accounts',
   BlockedAccounts: '/settings/blocked-accounts',
   Support: '/support',
diff --git a/src/view/screens/CustomAlgorithms.tsx b/src/view/screens/CustomAlgorithms.tsx
deleted file mode 100644
index b838660df..000000000
--- a/src/view/screens/CustomAlgorithms.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import React, {useCallback, useMemo} from 'react'
-import {
-  RefreshControl,
-  StyleSheet,
-  View,
-  FlatList,
-  ActivityIndicator,
-} 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 {SavedFeedsModel} from 'state/models/feeds/algo/saved'
-import AlgoItem from 'view/com/algos/AlgoItem'
-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} from 'platform/detection'
-import {s} from 'lib/styles'
-
-type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomAlgorithms'>
-
-const CustomAlgorithms = withAuthRequired(
-  observer(({}: Props) => {
-    const pal = usePalette('default')
-    const rootStore = useStores()
-    const {screen} = useAnalytics()
-
-    const savedFeeds = useMemo(
-      () => new SavedFeedsModel(rootStore),
-      [rootStore],
-    )
-
-    useFocusEffect(
-      useCallback(() => {
-        screen('SavedFeeds')
-        rootStore.shell.setMinimalShellMode(false)
-        savedFeeds.refresh()
-      }, [screen, rootStore, savedFeeds]),
-    )
-
-    return (
-      <CenteredView style={[s.flex1]}>
-        <ViewHeader title="Custom Algorithms" showOnDesktop />
-        <FlatList
-          style={[!isDesktopWeb && s.flex1]}
-          data={savedFeeds.feeds}
-          keyExtractor={item => item.data.uri}
-          refreshControl={
-            <RefreshControl
-              refreshing={savedFeeds.isRefreshing}
-              onRefresh={() => savedFeeds.refresh()}
-              tintColor={pal.colors.text}
-              titleColor={pal.colors.text}
-            />
-          }
-          onEndReached={() => savedFeeds.loadMore()}
-          renderItem={({item}) => <AlgoItem key={item.data.uri} item={item} />}
-          initialNumToRender={15}
-          ListFooterComponent={() => (
-            <View style={styles.footer}>
-              {savedFeeds.isLoading && <ActivityIndicator />}
-            </View>
-          )}
-          ListEmptyComponent={() => (
-            <View
-              style={[
-                pal.border,
-                !isDesktopWeb && s.flex1,
-                pal.viewLight,
-                styles.empty,
-              ]}>
-              <Text type="lg" style={[pal.text]}>
-                You don't have any saved feeds. To save a feed, click the save
-                button when a custom feed or algorithm shows up.
-              </Text>
-            </View>
-          )}
-          extraData={savedFeeds.isLoading}
-          // @ts-ignore our .web version only -prf
-          desktopFixedHeight
-        />
-      </CenteredView>
-    )
-  }),
-)
-
-export default CustomAlgorithms
-
-const styles = StyleSheet.create({
-  footer: {
-    paddingVertical: 20,
-  },
-  empty: {
-    paddingHorizontal: 20,
-    paddingVertical: 20,
-    borderRadius: 16,
-    marginHorizontal: 24,
-    marginTop: 10,
-  },
-})
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
new file mode 100644
index 000000000..7b04a6474
--- /dev/null
+++ b/src/view/screens/SavedFeeds.tsx
@@ -0,0 +1,103 @@
+import React, {useCallback, useMemo} from 'react'
+import {
+  RefreshControl,
+  StyleSheet,
+  View,
+  FlatList,
+  ActivityIndicator,
+} 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 {SavedFeedsModel} from 'state/models/feeds/algo/saved'
+import AlgoItem from 'view/com/algos/AlgoItem'
+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} from 'platform/detection'
+import {s} from 'lib/styles'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
+
+export const SavedFeeds = withAuthRequired(
+  observer(({}: Props) => {
+    const pal = usePalette('default')
+    const rootStore = useStores()
+    const {screen} = useAnalytics()
+
+    const savedFeeds = useMemo(
+      () => new SavedFeedsModel(rootStore),
+      [rootStore],
+    )
+
+    useFocusEffect(
+      useCallback(() => {
+        screen('SavedFeeds')
+        rootStore.shell.setMinimalShellMode(false)
+        savedFeeds.refresh()
+      }, [screen, rootStore, savedFeeds]),
+    )
+
+    return (
+      <CenteredView style={[s.flex1]}>
+        <ViewHeader title="Custom Algorithms" showOnDesktop />
+        <FlatList
+          style={[!isDesktopWeb && s.flex1]}
+          data={savedFeeds.feeds}
+          keyExtractor={item => item.data.uri}
+          refreshControl={
+            <RefreshControl
+              refreshing={savedFeeds.isRefreshing}
+              onRefresh={() => savedFeeds.refresh()}
+              tintColor={pal.colors.text}
+              titleColor={pal.colors.text}
+            />
+          }
+          onEndReached={() => savedFeeds.loadMore()}
+          renderItem={({item}) => <AlgoItem key={item.data.uri} item={item} />}
+          initialNumToRender={15}
+          ListFooterComponent={() => (
+            <View style={styles.footer}>
+              {savedFeeds.isLoading && <ActivityIndicator />}
+            </View>
+          )}
+          ListEmptyComponent={() => (
+            <View
+              style={[
+                pal.border,
+                !isDesktopWeb && s.flex1,
+                pal.viewLight,
+                styles.empty,
+              ]}>
+              <Text type="lg" style={[pal.text]}>
+                You don't have any saved feeds. To save a feed, click the save
+                button when a custom feed or algorithm shows up.
+              </Text>
+            </View>
+          )}
+          extraData={savedFeeds.isLoading}
+          // @ts-ignore our .web version only -prf
+          desktopFixedHeight
+        />
+      </CenteredView>
+    )
+  }),
+)
+
+const styles = StyleSheet.create({
+  footer: {
+    paddingVertical: 20,
+  },
+  empty: {
+    paddingHorizontal: 20,
+    paddingVertical: 20,
+    borderRadius: 16,
+    marginHorizontal: 24,
+    marginTop: 10,
+  },
+})
-- 
cgit 1.4.1


From 50108611600ae2addb3bff4f00562b02f9c35fdf Mon Sep 17 00:00:00 2001
From: Ansh Nanda <anshnanda10@gmail.com>
Date: Mon, 15 May 2023 10:42:23 -0700
Subject: custom feed screen

---
 src/Navigation.tsx                       |  2 ++
 src/lib/routes/types.ts                  |  1 +
 src/routes.ts                            |  1 +
 src/state/models/feeds/algo/algo-item.ts | 20 +++++++++++++
 src/state/models/feeds/posts.ts          | 31 +++++++++++++++-----
 src/view/com/algos/AlgoItem.tsx          | 24 +++++++++++++--
 src/view/screens/CustomFeed.tsx          | 50 ++++++++++++++++++++++++++++++++
 7 files changed, 119 insertions(+), 10 deletions(-)
 create mode 100644 src/view/screens/CustomFeed.tsx

(limited to 'src/Navigation.tsx')

diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 26dc9f7ad..d4c992eb6 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -53,6 +53,7 @@ import {MutedAccounts} from 'view/screens/MutedAccounts'
 import {BlockedAccounts} from 'view/screens/BlockedAccounts'
 import {getRoutingInstrumentation} from 'lib/sentry'
 import {SavedFeeds} from './view/screens/SavedFeeds'
+import {CustomFeed} from './view/screens/CustomFeed'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
 
@@ -93,6 +94,7 @@ function commonScreens(Stack: typeof HomeTab) {
       <Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
       <Stack.Screen name="AppPasswords" component={AppPasswords} />
       <Stack.Screen name="SavedFeeds" component={SavedFeeds} />
+      <Stack.Screen name="CustomFeed" component={CustomFeed} />
       <Stack.Screen name="MutedAccounts" component={MutedAccounts} />
       <Stack.Screen name="BlockedAccounts" component={BlockedAccounts} />
     </>
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 5a4126f6a..29fadd709 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -21,6 +21,7 @@ export type CommonNavigatorParams = {
   CopyrightPolicy: undefined
   AppPasswords: undefined
   SavedFeeds: undefined
+  CustomFeed: {name: string; rkey: string}
   MutedAccounts: undefined
   BlockedAccounts: undefined
 }
diff --git a/src/routes.ts b/src/routes.ts
index b510f66f6..2cdaa28bb 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -15,6 +15,7 @@ export const router = new Router({
   Log: '/sys/log',
   AppPasswords: '/settings/app-passwords',
   SavedFeeds: '/settings/saved-feeds',
+  CustomFeed: '/profile/:name/feed/:rkey',
   MutedAccounts: '/settings/muted-accounts',
   BlockedAccounts: '/settings/blocked-accounts',
   Support: '/support',
diff --git a/src/state/models/feeds/algo/algo-item.ts b/src/state/models/feeds/algo/algo-item.ts
index 88e9c0662..3dee5e2bf 100644
--- a/src/state/models/feeds/algo/algo-item.ts
+++ b/src/state/models/feeds/algo/algo-item.ts
@@ -29,6 +29,10 @@ export class AlgoItemModel {
     }
   }
 
+  get getUri() {
+    return this.data.uri
+  }
+
   // public apis
   // =
   async save() {
@@ -52,4 +56,20 @@ export class AlgoItemModel {
       this.rootStore.log.error('Failed to unsanve feed', e)
     }
   }
+
+  // async getFeedSkeleton() {
+  //   const res = await this.rootStore.agent.app.bsky.feed.getFeedSkeleton({
+  //     feed: this.data.uri,
+  //   })
+  //   const skeleton = res.data.feed
+  //   console.log('skeleton', skeleton)
+  //   return skeleton
+  // }
+  // async getFeed() {
+  //   const feed = await this.rootStore.agent.app.bsky.feed.getFeed({
+  //     feed: this.data.uri,
+  //   })
+  //   console.log('feed', feed)
+  //   return feed
+  // }
 }
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index 44cec3af7..7adc1cb1c 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -4,6 +4,7 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
   AppBskyFeedGetAuthorFeed as GetAuthorFeed,
+  AppBskyFeedGetFeed as GetCustomFeed,
   RichText,
   jsonToLex,
 } from '@atproto/api'
@@ -305,8 +306,11 @@ export class PostsFeedModel {
 
   constructor(
     public rootStore: RootStoreModel,
-    public feedType: 'home' | 'author' | 'suggested' | 'goodstuff',
-    params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams,
+    public feedType: 'home' | 'author' | 'suggested' | 'goodstuff' | 'custom',
+    params:
+      | GetTimeline.QueryParams
+      | GetAuthorFeed.QueryParams
+      | GetCustomFeed.QueryParams,
   ) {
     makeAutoObservable(
       this,
@@ -595,13 +599,15 @@ export class PostsFeedModel {
   // helper functions
   // =
 
-  async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
+  async _replaceAll(
+    res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
+  ) {
     this.pollCursor = res.data.feed[0]?.post.uri
     return this._appendAll(res, true)
   }
 
   async _appendAll(
-    res: GetTimeline.Response | GetAuthorFeed.Response,
+    res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
     replace = false,
   ) {
     this.loadMoreCursor = res.data.cursor
@@ -640,7 +646,9 @@ export class PostsFeedModel {
     })
   }
 
-  _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
+  _updateAll(
+    res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
+  ) {
     for (const item of res.data.feed) {
       const existingSlice = this.slices.find(slice =>
         slice.containsUri(item.post.uri),
@@ -657,8 +665,13 @@ export class PostsFeedModel {
   }
 
   protected async _getFeed(
-    params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {},
-  ): Promise<GetTimeline.Response | GetAuthorFeed.Response> {
+    params:
+      | GetTimeline.QueryParams
+      | GetAuthorFeed.QueryParams
+      | GetCustomFeed.QueryParams,
+  ): Promise<
+    GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response
+  > {
     params = Object.assign({}, this.params, params)
     if (this.feedType === 'suggested') {
       const responses = await getMultipleAuthorsPosts(
@@ -680,6 +693,10 @@ export class PostsFeedModel {
       }
     } else if (this.feedType === 'home') {
       return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams)
+    } else if (this.feedType === 'custom') {
+      return this.rootStore.agent.app.bsky.feed.getFeed(
+        params as GetCustomFeed.QueryParams,
+      )
     } else if (this.feedType === 'goodstuff') {
       const res = await getGoodStuff(
         this.rootStore.session.currentSession?.accessJwt || '',
diff --git a/src/view/com/algos/AlgoItem.tsx b/src/view/com/algos/AlgoItem.tsx
index e475624c5..51de89bd6 100644
--- a/src/view/com/algos/AlgoItem.tsx
+++ b/src/view/com/algos/AlgoItem.tsx
@@ -1,5 +1,11 @@
 import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+  TouchableOpacity,
+} from 'react-native'
 import {Text} from '../util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
@@ -7,13 +13,25 @@ import {UserAvatar} from '../util/UserAvatar'
 import {Button} from '../util/forms/Button'
 import {observer} from 'mobx-react-lite'
 import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
+import {useNavigation} from '@react-navigation/native'
+import {NavigationProp} from 'lib/routes/types'
 
 const AlgoItem = observer(
   ({item, style}: {item: AlgoItemModel; style?: StyleProp<ViewStyle>}) => {
     const pal = usePalette('default')
+    const navigation = useNavigation<NavigationProp>()
 
     return (
-      <View style={[styles.container, style]} key={item.data.uri}>
+      <TouchableOpacity
+        accessibilityRole="button"
+        style={[styles.container, style]}
+        onPress={() => {
+          navigation.navigate('CustomFeed', {
+            name: item.data.creator.did,
+            rkey: item.data.uri,
+          })
+        }}
+        key={item.data.uri}>
         <View style={[styles.headerContainer]}>
           <View style={[s.mr10]}>
             <UserAvatar size={36} avatar={item.data.avatar} />
@@ -54,7 +72,7 @@ const AlgoItem = observer(
             />
           </View>
         </View>
-      </View>
+      </TouchableOpacity>
     )
   },
 )
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx
new file mode 100644
index 000000000..1d4343b29
--- /dev/null
+++ b/src/view/screens/CustomFeed.tsx
@@ -0,0 +1,50 @@
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {CommonNavigatorParams} from 'lib/routes/types'
+import {observer} from 'mobx-react-lite'
+import React, {useEffect, useMemo, useRef} from 'react'
+import {FlatList, StyleSheet, View} from 'react-native'
+import {useStores} from 'state/index'
+import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
+import {PostsFeedModel} from 'state/models/feeds/posts'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {Feed} from 'view/com/posts/Feed'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {Text} from 'view/com/util/text/Text'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'>
+export const CustomFeed = withAuthRequired(
+  observer(({route}: Props) => {
+    const rootStore = useStores()
+    const scrollElRef = useRef<FlatList>(null)
+
+    const {rkey, name} = route.params
+
+    const algoFeed: PostsFeedModel = useMemo(() => {
+      const feed = new PostsFeedModel(rootStore, 'custom', {
+        feed: rkey,
+      })
+      feed.setup()
+      return feed
+    }, [rkey, rootStore])
+
+    return (
+      <View style={[styles.container]}>
+        <ViewHeader title={'Custom Feed'} showOnDesktop />
+
+        <Feed
+          scrollElRef={scrollElRef}
+          testID={'test-feed'}
+          key="default"
+          feed={algoFeed}
+        />
+      </View>
+    )
+  }),
+)
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    height: '100%',
+  },
+})
-- 
cgit 1.4.1


From 53ca0cd626cc71fea38fb0f59f68092ab406d143 Mon Sep 17 00:00:00 2001
From: Ansh Nanda <anshnanda10@gmail.com>
Date: Tue, 16 May 2023 18:28:44 -0700
Subject: drag to rearrange pinned items

---
 src/Navigation.tsx                   |   2 +
 src/lib/routes/types.ts              |   1 +
 src/routes.ts                        |   1 +
 src/view/com/algos/AlgoItem.tsx      |   8 +--
 src/view/com/algos/SavedFeedItem.tsx |  50 +++++++++++++
 src/view/screens/PinnedFeeds.tsx     | 134 +++++++++++++++++++++++++++++++++++
 src/view/screens/SavedFeeds.tsx      |  99 +++++++++++++++++---------
 7 files changed, 256 insertions(+), 39 deletions(-)
 create mode 100644 src/view/com/algos/SavedFeedItem.tsx
 create mode 100644 src/view/screens/PinnedFeeds.tsx

(limited to 'src/Navigation.tsx')

diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index d4c992eb6..17d80dfdc 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -54,6 +54,7 @@ import {BlockedAccounts} from 'view/screens/BlockedAccounts'
 import {getRoutingInstrumentation} from 'lib/sentry'
 import {SavedFeeds} from './view/screens/SavedFeeds'
 import {CustomFeed} from './view/screens/CustomFeed'
+import {PinnedFeeds} from 'view/screens/PinnedFeeds'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
 
@@ -94,6 +95,7 @@ function commonScreens(Stack: typeof HomeTab) {
       <Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
       <Stack.Screen name="AppPasswords" component={AppPasswords} />
       <Stack.Screen name="SavedFeeds" component={SavedFeeds} />
+      <Stack.Screen name="PinnedFeeds" component={PinnedFeeds} />
       <Stack.Screen name="CustomFeed" component={CustomFeed} />
       <Stack.Screen name="MutedAccounts" component={MutedAccounts} />
       <Stack.Screen name="BlockedAccounts" component={BlockedAccounts} />
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 77ed58cc3..12ff27070 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -21,6 +21,7 @@ export type CommonNavigatorParams = {
   CopyrightPolicy: undefined
   AppPasswords: undefined
   SavedFeeds: undefined
+  PinnedFeeds: undefined
   CustomFeed: {name?: string; rkey: string}
   MutedAccounts: undefined
   BlockedAccounts: undefined
diff --git a/src/routes.ts b/src/routes.ts
index 2cdaa28bb..6998226c0 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -15,6 +15,7 @@ export const router = new Router({
   Log: '/sys/log',
   AppPasswords: '/settings/app-passwords',
   SavedFeeds: '/settings/saved-feeds',
+  PinnedFeeds: '/settings/pinned-feeds',
   CustomFeed: '/profile/:name/feed/:rkey',
   MutedAccounts: '/settings/muted-accounts',
   BlockedAccounts: '/settings/blocked-accounts',
diff --git a/src/view/com/algos/AlgoItem.tsx b/src/view/com/algos/AlgoItem.tsx
index 6fbbd0df1..b28545c17 100644
--- a/src/view/com/algos/AlgoItem.tsx
+++ b/src/view/com/algos/AlgoItem.tsx
@@ -24,13 +24,11 @@ const AlgoItem = observer(
     item,
     style,
     showBottom = true,
-    onLongPress,
     reloadOnFocus = false,
   }: {
     item: AlgoItemModel
     style?: StyleProp<ViewStyle>
     showBottom?: boolean
-    onLongPress?: () => void
     reloadOnFocus?: boolean
   }) => {
     const store = useStores()
@@ -54,7 +52,6 @@ const AlgoItem = observer(
             rkey: item.data.uri,
           })
         }}
-        onLongPress={onLongPress}
         key={item.data.uri}>
         <View style={[styles.headerContainer]}>
           <View style={[s.mr10]}>
@@ -64,8 +61,9 @@ const AlgoItem = observer(
             <Text style={[pal.text, s.bold]}>
               {item.data.displayName ?? 'Feed name'}
             </Text>
-            <Text style={[pal.textLight, styles.description]}>
-              {item.data.description ?? 'Feed description'}
+            <Text style={[pal.textLight, styles.description]} numberOfLines={5}>
+              {item.data.description ??
+                "Explore our Feed for the latest updates and insights! Dive into a world of intriguing articles, trending news, and exciting stories that cover a wide range of topics. From technology breakthroughs to lifestyle tips, there's something here for everyone. Stay informed and get inspired with us. Join the conversation now!"}
             </Text>
           </View>
         </View>
diff --git a/src/view/com/algos/SavedFeedItem.tsx b/src/view/com/algos/SavedFeedItem.tsx
new file mode 100644
index 000000000..bb4ec10b3
--- /dev/null
+++ b/src/view/com/algos/SavedFeedItem.tsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import {View, TouchableOpacity, StyleSheet} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {colors} from 'lib/styles'
+import {observer} from 'mobx-react-lite'
+import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
+import {SavedFeedsModel} from 'state/models/feeds/algo/saved'
+import AlgoItem from './AlgoItem'
+
+export const SavedFeedItem = observer(
+  ({item, savedFeeds}: {item: AlgoItemModel; savedFeeds: SavedFeedsModel}) => {
+    const isPinned = savedFeeds.isPinned(item)
+
+    return (
+      <View style={styles.itemContainer}>
+        <AlgoItem
+          key={item.data.uri}
+          item={item}
+          showBottom={false}
+          style={styles.item}
+        />
+        <TouchableOpacity
+          accessibilityRole="button"
+          onPress={() => {
+            savedFeeds.togglePinnedFeed(item)
+            console.log('pinned', savedFeeds.pinned)
+            console.log('isPinned', savedFeeds.isPinned(item))
+          }}>
+          <FontAwesomeIcon
+            icon="thumb-tack"
+            size={20}
+            color={isPinned ? colors.blue3 : colors.gray3}
+          />
+        </TouchableOpacity>
+      </View>
+    )
+  },
+)
+
+const styles = StyleSheet.create({
+  itemContainer: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+    marginRight: 18,
+  },
+  item: {
+    borderTopWidth: 0,
+  },
+})
diff --git a/src/view/screens/PinnedFeeds.tsx b/src/view/screens/PinnedFeeds.tsx
new file mode 100644
index 000000000..2a0e3deff
--- /dev/null
+++ b/src/view/screens/PinnedFeeds.tsx
@@ -0,0 +1,134 @@
+import React, {useCallback, useMemo} from 'react'
+import {
+  RefreshControl,
+  StyleSheet,
+  View,
+  ActivityIndicator,
+  Pressable,
+} 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} from 'platform/detection'
+import {s} from 'lib/styles'
+import DraggableFlatList, {
+  ShadowDecorator,
+  ScaleDecorator,
+} from 'react-native-draggable-flatlist'
+import {SavedFeedItem} from 'view/com/algos/SavedFeedItem'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'PinnedFeeds'>
+
+export const PinnedFeeds = withAuthRequired(
+  observer(({}: Props) => {
+    // hooks for global items
+    const pal = usePalette('default')
+    const rootStore = useStores()
+    const {screen} = useAnalytics()
+
+    // hooks for local
+    const savedFeeds = useMemo(() => rootStore.me.savedFeeds, [rootStore])
+    useFocusEffect(
+      useCallback(() => {
+        screen('SavedFeeds')
+        rootStore.shell.setMinimalShellMode(false)
+        savedFeeds.refresh()
+      }, [screen, rootStore, savedFeeds]),
+    )
+    const _ListEmptyComponent = () => {
+      return (
+        <View
+          style={[
+            pal.border,
+            !isDesktopWeb && s.flex1,
+            pal.viewLight,
+            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!
+          </Text>
+        </View>
+      )
+    }
+    const _ListFooterComponent = () => {
+      return (
+        <View style={styles.footer}>
+          {savedFeeds.isLoading && <ActivityIndicator />}
+        </View>
+      )
+    }
+
+    return (
+      <CenteredView style={[s.flex1]}>
+        <ViewHeader title="Arrange Pinned Feeds" showOnDesktop />
+        <DraggableFlatList
+          containerStyle={[!isDesktopWeb && s.flex1]}
+          data={savedFeeds.pinned}
+          keyExtractor={item => item.data.uri}
+          refreshing={savedFeeds.isRefreshing}
+          refreshControl={
+            <RefreshControl
+              refreshing={savedFeeds.isRefreshing}
+              onRefresh={() => savedFeeds.refresh()}
+              tintColor={pal.colors.text}
+              titleColor={pal.colors.text}
+            />
+          }
+          renderItem={({item, drag}) => (
+            <ScaleDecorator>
+              <ShadowDecorator>
+                <Pressable
+                  accessibilityRole="button"
+                  onLongPress={drag}
+                  style={styles.itemContainer}>
+                  <FontAwesomeIcon icon="bars" size={20} style={styles.icon} />
+                  <SavedFeedItem item={item} savedFeeds={savedFeeds} />
+                </Pressable>
+              </ShadowDecorator>
+            </ScaleDecorator>
+          )}
+          initialNumToRender={10}
+          ListFooterComponent={_ListFooterComponent}
+          ListEmptyComponent={_ListEmptyComponent}
+          extraData={savedFeeds.isLoading}
+          onDragEnd={({data}) => savedFeeds.reorderPinnedFeeds(data)}
+          // @ts-ignore our .web version only -prf
+          desktopFixedHeight
+        />
+      </CenteredView>
+    )
+  }),
+)
+
+const styles = StyleSheet.create({
+  footer: {
+    paddingVertical: 20,
+  },
+  empty: {
+    paddingHorizontal: 20,
+    paddingVertical: 20,
+    borderRadius: 16,
+    marginHorizontal: 24,
+    marginTop: 10,
+  },
+  itemContainer: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+    marginLeft: 18,
+  },
+  item: {
+    borderTopWidth: 0,
+  },
+  icon: {marginRight: 10},
+})
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index 65ffdb233..8403efc6e 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -6,6 +6,7 @@ import {
   ActivityIndicator,
   FlatList,
   TouchableOpacity,
+  ScrollView,
 } from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -14,21 +15,21 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {CommonNavigatorParams} from 'lib/routes/types'
 import {observer} from 'mobx-react-lite'
 import {useStores} from 'state/index'
-import AlgoItem from 'view/com/algos/AlgoItem'
 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} from 'platform/detection'
-import {colors, s} from 'lib/styles'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
+import {s} from 'lib/styles'
 import {SavedFeedsModel} from 'state/models/feeds/algo/saved'
+import {Link} from 'view/com/util/Link'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {SavedFeedItem} from 'view/com/algos/SavedFeedItem'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
 
 export const SavedFeeds = withAuthRequired(
-  observer(({}: Props) => {
+  observer(({navigation}: Props) => {
     // hooks for global items
     const pal = usePalette('default')
     const rootStore = useStores()
@@ -87,6 +88,12 @@ export const SavedFeeds = withAuthRequired(
             <SavedFeedItem item={item} savedFeeds={savedFeeds} />
           )}
           initialNumToRender={10}
+          ListHeaderComponent={() => (
+            <ListHeaderComponent
+              savedFeeds={savedFeeds}
+              navigation={navigation}
+            />
+          )}
           ListFooterComponent={_ListFooterComponent}
           ListEmptyComponent={_ListEmptyComponent}
           extraData={savedFeeds.isLoading}
@@ -98,31 +105,53 @@ export const SavedFeeds = withAuthRequired(
   }),
 )
 
-const SavedFeedItem = observer(
-  ({item, savedFeeds}: {item: AlgoItemModel; savedFeeds: SavedFeedsModel}) => {
-    const isPinned = savedFeeds.isPinned(item)
-
+const ListHeaderComponent = observer(
+  ({
+    savedFeeds,
+    navigation,
+  }: {
+    savedFeeds: SavedFeedsModel
+    navigation: Props['navigation']
+  }) => {
     return (
-      <View style={styles.itemContainer}>
-        <AlgoItem
-          key={item.data.uri}
-          item={item}
-          showBottom={false}
-          style={styles.item}
-        />
-        <TouchableOpacity
-          accessibilityRole="button"
-          onPress={() => {
-            savedFeeds.togglePinnedFeed(item)
-            console.log('pinned', savedFeeds.pinned)
-            console.log('isPinned', savedFeeds.isPinned(item))
-          }}>
-          <FontAwesomeIcon
-            icon="thumb-tack"
-            size={20}
-            color={isPinned ? colors.blue3 : colors.gray3}
-          />
-        </TouchableOpacity>
+      <View style={styles.headerContainer}>
+        {savedFeeds.pinned.length > 0 ? (
+          <View style={styles.pinnedContainer}>
+            <View style={styles.pinnedHeader}>
+              <Text type="lg-bold">Pinned Feeds</Text>
+              <Link href="/settings/pinned-feeds">
+                <Text style={styles.editPinned}>Edit</Text>
+              </Link>
+            </View>
+
+            <ScrollView
+              horizontal={true}
+              showsHorizontalScrollIndicator={false}>
+              {savedFeeds.pinned.map(item => {
+                return (
+                  <TouchableOpacity
+                    key={item.data.uri}
+                    accessibilityRole="button"
+                    onPress={() => {
+                      navigation.navigate('CustomFeed', {
+                        rkey: item.data.uri,
+                        name: item.data.displayName,
+                      })
+                    }}
+                    style={styles.pinnedItem}>
+                    <UserAvatar avatar={item.data.avatar} size={80} />
+                    <Text type="sm-medium" numberOfLines={1}>
+                      {item.data.displayName ??
+                        `${item.data.creator.displayName}'s feed`}
+                    </Text>
+                  </TouchableOpacity>
+                )
+              })}
+            </ScrollView>
+          </View>
+        ) : null}
+
+        <Text type="lg-bold">All Saved Feeds</Text>
       </View>
     )
   },
@@ -139,12 +168,14 @@ const styles = StyleSheet.create({
     marginHorizontal: 24,
     marginTop: 10,
   },
-  itemContainer: {
-    flexDirection: 'row',
+  headerContainer: {paddingHorizontal: 18},
+  pinnedContainer: {marginBottom: 18, gap: 18},
+  pinnedHeader: {flexDirection: 'row', justifyContent: 'space-between'},
+  pinnedItem: {
+    flex: 1,
     alignItems: 'center',
     marginRight: 18,
+    maxWidth: 100,
   },
-  item: {
-    borderTopWidth: 0,
-  },
+  editPinned: {textDecorationLine: 'underline'},
 })
-- 
cgit 1.4.1


From 85910cdbea73352a0512218d4a3851ad52e7c8b3 Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
Date: Wed, 17 May 2023 13:42:16 -0500
Subject: Fix missed change in merge

---
 src/Navigation.tsx | 2 --
 1 file changed, 2 deletions(-)

(limited to 'src/Navigation.tsx')

diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 45ab439b6..4521068f2 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -187,8 +187,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         component={AppPasswords}
         options={{title: title('App Passwords')}}
       />
-      <Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
-      <Stack.Screen name="AppPasswords" component={AppPasswords} />
       <Stack.Screen name="SavedFeeds" component={SavedFeeds} />
       <Stack.Screen name="PinnedFeeds" component={PinnedFeeds} />
       <Stack.Screen name="CustomFeed" component={CustomFeed} />
-- 
cgit 1.4.1


From c55ce6de020d6c9df86c71158251caddf12da777 Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
Date: Wed, 17 May 2023 15:52:11 -0500
Subject: Rework the UI for the custom feed view

---
 src/Navigation.tsx                     |   4 +-
 src/lib/hooks/useCustomFeed.ts         |   4 +-
 src/lib/routes/types.ts                |   2 +-
 src/view/com/feeds/CustomFeed.tsx      |   3 +-
 src/view/screens/CustomFeed.tsx        | 164 -------------------
 src/view/screens/ProfileCustomFeed.tsx | 291 +++++++++++++++++++++++++++++++++
 src/view/screens/ProfileList.tsx       |   4 +-
 src/view/screens/SavedFeeds.tsx        |   5 +-
 8 files changed, 300 insertions(+), 177 deletions(-)
 delete mode 100644 src/view/screens/CustomFeed.tsx
 create mode 100644 src/view/screens/ProfileCustomFeed.tsx

(limited to 'src/Navigation.tsx')

diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 4521068f2..025020afa 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -40,6 +40,7 @@ import {SettingsScreen} from './view/screens/Settings'
 import {ProfileScreen} from './view/screens/Profile'
 import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
 import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
+import {ProfileCustomFeed} from './view/screens/ProfileCustomFeed'
 import {ProfileListScreen} from './view/screens/ProfileList'
 import {PostThreadScreen} from './view/screens/PostThread'
 import {PostLikedByScreen} from './view/screens/PostLikedBy'
@@ -56,7 +57,6 @@ import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
 import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
 import {getRoutingInstrumentation} from 'lib/sentry'
 import {SavedFeeds} from './view/screens/SavedFeeds'
-import {CustomFeed} from './view/screens/CustomFeed'
 import {PinnedFeeds} from 'view/screens/PinnedFeeds'
 import {bskyTitle} from 'lib/strings/headings'
 
@@ -127,6 +127,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
           title: title(`People followed by @${route.params.name}`),
         })}
       />
+      <Stack.Screen name="ProfileCustomFeed" component={ProfileCustomFeed} />
       <Stack.Screen
         name="ProfileList"
         component={ProfileListScreen}
@@ -189,7 +190,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
       />
       <Stack.Screen name="SavedFeeds" component={SavedFeeds} />
       <Stack.Screen name="PinnedFeeds" component={PinnedFeeds} />
-      <Stack.Screen name="CustomFeed" component={CustomFeed} />
     </>
   )
 }
diff --git a/src/lib/hooks/useCustomFeed.ts b/src/lib/hooks/useCustomFeed.ts
index ee40cf49e..d7a27050d 100644
--- a/src/lib/hooks/useCustomFeed.ts
+++ b/src/lib/hooks/useCustomFeed.ts
@@ -2,9 +2,9 @@ import {useEffect, useState} from 'react'
 import {useStores} from 'state/index'
 import {CustomFeedModel} from 'state/models/feeds/custom-feed'
 
-export function useCustomFeed(uri: string) {
+export function useCustomFeed(uri: string): CustomFeedModel | undefined {
   const store = useStores()
-  const [item, setItem] = useState<CustomFeedModel>()
+  const [item, setItem] = useState<CustomFeedModel | undefined>()
   useEffect(() => {
     async function fetchView() {
       const res = await store.agent.app.bsky.feed.getFeedGenerator({
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 8b96aaad7..52d0e9af2 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -13,11 +13,11 @@ export type CommonNavigatorParams = {
   Profile: {name: string; hideBackButton?: boolean}
   ProfileFollowers: {name: string}
   ProfileFollows: {name: string}
+  ProfileCustomFeed: {name: string; rkey: string}
   ProfileList: {name: string; rkey: string}
   PostThread: {name: string; rkey: string}
   PostLikedBy: {name: string; rkey: string}
   PostRepostedBy: {name: string; rkey: string}
-  CustomFeed: {name: string; rkey: string; displayName?: string}
   Debug: undefined
   Log: undefined
   Support: undefined
diff --git a/src/view/com/feeds/CustomFeed.tsx b/src/view/com/feeds/CustomFeed.tsx
index 8e1a78453..5a93020a0 100644
--- a/src/view/com/feeds/CustomFeed.tsx
+++ b/src/view/com/feeds/CustomFeed.tsx
@@ -40,10 +40,9 @@ export const CustomFeed = observer(
         accessibilityRole="button"
         style={[styles.container, pal.border, style]}
         onPress={() => {
-          navigation.navigate('CustomFeed', {
+          navigation.navigate('ProfileCustomFeed', {
             name: item.data.creator.did,
             rkey: new AtUri(item.data.uri).rkey,
-            displayName: item.displayName,
           })
         }}
         key={item.data.uri}>
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx
deleted file mode 100644
index 76125fa5c..000000000
--- a/src/view/screens/CustomFeed.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-import {NativeStackScreenProps} from '@react-navigation/native-stack'
-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 React, {useMemo, useRef} from 'react'
-import {FlatList, StyleSheet, TouchableOpacity, 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 {Link} 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'
-
-type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'>
-export const CustomFeed = withAuthRequired(
-  observer(({route}: Props) => {
-    const rootStore = useStores()
-    const {rkey, name, displayName} = route.params
-    const uri = useMemo(
-      () => makeRecordUri(name, 'app.bsky.feed.generator', rkey),
-      [rkey, name],
-    )
-    const currentFeed = useCustomFeed(uri)
-    const scrollElRef = useRef<FlatList>(null)
-    const algoFeed: PostsFeedModel = useMemo(() => {
-      const feed = new PostsFeedModel(rootStore, 'custom', {
-        feed: uri,
-      })
-      feed.setup()
-      return feed
-    }, [rootStore, uri])
-
-    return (
-      <View style={[styles.container]}>
-        <ViewHeader
-          title={
-            displayName ?? `${currentFeed?.data.creator.displayName}'s feed`
-          }
-          showOnDesktop
-        />
-        <Feed
-          scrollElRef={scrollElRef}
-          testID={'test-feed'}
-          key="default"
-          feed={algoFeed}
-          headerOffset={12}
-          ListHeaderComponent={() => <ListHeaderComponent uri={uri} />}
-          extraData={uri}
-        />
-      </View>
-    )
-  }),
-)
-
-const ListHeaderComponent = observer(({uri}: {uri: string}) => {
-  const currentFeed = useCustomFeed(uri)
-  const pal = usePalette('default')
-  const rootStore = useStores()
-  return (
-    <View style={[styles.headerContainer]}>
-      <View style={[styles.header]}>
-        <View style={styles.avatarContainer}>
-          <UserAvatar
-            type="algo"
-            size={28}
-            avatar={currentFeed?.data.creator.avatar}
-          />
-          <Link href={`/profile/${currentFeed?.data.creator.handle}`}>
-            <Text style={[pal.textLight]}>
-              @{currentFeed?.data.creator.handle}
-            </Text>
-          </Link>
-        </View>
-        <Text style={[pal.text]}>{currentFeed?.data.description}</Text>
-      </View>
-
-      <View style={[styles.buttonsContainer]}>
-        <Button
-          type={currentFeed?.isSaved ? 'default' : 'inverted'}
-          style={[styles.saveButton]}
-          onPress={() => {
-            if (currentFeed?.data.viewer?.saved) {
-              rootStore.me.savedFeeds.unsave(currentFeed!)
-            } else {
-              rootStore.me.savedFeeds.save(currentFeed!)
-            }
-          }}
-          label={currentFeed?.data.viewer?.saved ? 'Unsave' : 'Save'}
-        />
-
-        <TouchableOpacity
-          accessibilityRole="button"
-          onPress={() => {
-            if (currentFeed?.isLiked) {
-              currentFeed?.unlike()
-            } else {
-              currentFeed?.like()
-            }
-          }}
-          style={[styles.likeButton, pal.viewLight]}>
-          <Text style={[pal.text, s.semiBold]}>
-            {currentFeed?.data.likeCount}
-          </Text>
-          {currentFeed?.isLiked ? (
-            <HeartIconSolid size={18} style={styles.liked} />
-          ) : (
-            <HeartIcon strokeWidth={3} size={18} style={styles.liked} />
-          )}
-        </TouchableOpacity>
-      </View>
-    </View>
-  )
-})
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-  },
-  headerContainer: {
-    alignItems: 'center',
-    justifyContent: 'center',
-    gap: 8,
-    marginBottom: 12,
-  },
-  header: {
-    alignItems: 'center',
-    gap: 4,
-  },
-  avatarContainer: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    gap: 8,
-  },
-  buttonsContainer: {
-    flexDirection: 'row',
-    gap: 8,
-  },
-  saveButton: {
-    minWidth: 100,
-    alignItems: 'center',
-  },
-  liked: {
-    color: colors.red3,
-  },
-  notLiked: {
-    color: colors.gray3,
-  },
-  likeButton: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    paddingVertical: 4,
-    paddingHorizontal: 8,
-    borderRadius: 24,
-    gap: 4,
-  },
-})
diff --git a/src/view/screens/ProfileCustomFeed.tsx b/src/view/screens/ProfileCustomFeed.tsx
new file mode 100644
index 000000000..1113ebf01
--- /dev/null
+++ b/src/view/screens/ProfileCustomFeed.tsx
@@ -0,0 +1,291 @@
+import React, {useMemo, useRef} from 'react'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+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'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileCustomFeed'>
+export const ProfileCustomFeed = 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 onToggleSaved = React.useCallback(async () => {
+      try {
+        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 () => {
+      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 renderHeaderBtns = React.useCallback(() => {
+      return (
+        <View style={styles.headerBtns}>
+          <Button
+            type="default"
+            testID="toggleLikeBtn"
+            accessibilityLabel="Like this feed"
+            accessibilityHint=""
+            onPress={onToggleLiked}>
+            {currentFeed?.isLiked ? (
+              <HeartIconSolid size={18} style={styles.liked} />
+            ) : (
+              <HeartIcon strokeWidth={3} size={18} style={pal.textLight} />
+            )}
+          </Button>
+          <Button
+            type={currentFeed?.isSaved ? 'default' : 'inverted'}
+            onPress={onToggleSaved}
+            accessibilityLabel={
+              currentFeed?.isSaved ? 'Unsave this feed' : 'Save this feed'
+            }
+            accessibilityHint=""
+            label={currentFeed?.isSaved ? 'Unsave' : 'Save'}
+          />
+        </View>
+      )
+    }, [
+      pal,
+      currentFeed?.isSaved,
+      currentFeed?.isLiked,
+      onToggleSaved,
+      onToggleLiked,
+    ])
+
+    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}`}
+                    />
+                  )}
+                </Text>
+              )}
+            </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}
+            <Text type="md-medium" style={pal.textLight}>
+              Liked by {currentFeed?.data.likeCount}{' '}
+              {pluralize(currentFeed?.data.likeCount || 0, 'user')}
+            </Text>
+            {isDesktopWeb && (
+              <View style={styles.headerBtns}>
+                <Button
+                  type={currentFeed?.isSaved ? 'default' : 'inverted'}
+                  onPress={onToggleSaved}
+                  accessibilityLabel={
+                    currentFeed?.isSaved ? 'Unsave this feed' : 'Save this feed'
+                  }
+                  accessibilityHint=""
+                  label={currentFeed?.isSaved ? 'Unsave' : 'Save'}
+                />
+
+                <Button type="default" onPress={onToggleLiked}>
+                  {currentFeed?.isLiked ? (
+                    <HeartIconSolid size={18} style={styles.liked} />
+                  ) : (
+                    <HeartIcon strokeWidth={3} size={18} style={pal.icon} />
+                  )}
+                </Button>
+              </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>
+        </>
+      )
+    }, [store.me.did, pal, currentFeed, onToggleLiked, onToggleSaved])
+
+    return (
+      <View style={s.hContentRegion}>
+        <ViewHeader title="" renderButton={renderHeaderBtns} />
+        <Feed
+          scrollElRef={scrollElRef}
+          feed={algoFeed}
+          ListHeaderComponent={renderListHeaderComponent}
+          extraData={uri}
+        />
+      </View>
+    )
+  }),
+)
+
+/*
+
+        <View style={[styles.headerContainer]}>
+          <View style={[styles.header]}>
+            <View style={styles.avatarContainer}>
+              <UserAvatar
+                type="algo"
+                size={28}
+                avatar={currentFeed?.data.avatar}
+              />
+              <Link href={`/profile/${currentFeed?.data.creator.handle}`}>
+                <Text style={[pal.textLight]}>
+                  @{currentFeed?.data.creator.handle}
+                </Text>
+              </Link>
+            </View>
+            <Text style={[pal.text]}>{currentFeed?.data.description}</Text>
+          </View>
+
+          <View style={[styles.buttonsContainer]}>
+          </View>
+        </View>
+        */
+
+const styles = StyleSheet.create({
+  headerBtns: {
+    flexDirection: 'row',
+    gap: 8,
+  },
+  header: {
+    flexDirection: 'row',
+    gap: 12,
+    paddingHorizontal: 16,
+    paddingTop: 12,
+    paddingBottom: 16,
+    borderTopWidth: 1,
+  },
+  headerDetails: {
+    paddingHorizontal: 16,
+    paddingBottom: 16,
+  },
+  fakeSelector: {
+    flexDirection: 'row',
+    paddingHorizontal: isDesktopWeb ? 16 : 6,
+  },
+  fakeSelectorItem: {
+    paddingHorizontal: 12,
+    paddingBottom: 8,
+    borderBottomWidth: 3,
+  },
+  liked: {
+    color: colors.red3,
+  },
+
+  /*  headerContainer: {
+    alignItems: 'center',
+    justifyContent: 'center',
+    gap: 8,
+    marginBottom: 12,
+  },
+  header: {
+    alignItems: 'center',
+    gap: 4,
+  },
+  avatarContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 8,
+  },
+  buttonsContainer: {
+    flexDirection: 'row',
+    gap: 8,
+  },
+  saveButton: {
+    minWidth: 100,
+    alignItems: 'center',
+  },
+  liked: {
+    color: colors.red3,
+  },
+  notLiked: {
+    color: colors.gray3,
+  },
+  likeButton: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    paddingVertical: 4,
+    paddingHorizontal: 8,
+    borderRadius: 24,
+    gap: 4,
+  },*/
+})
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
index 6d55649f7..c32639889 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -137,12 +137,9 @@ const ListHeaderComponent = observer(
                     key={item.data.uri}
                     accessibilityRole="button"
                     onPress={() => {
-                      navigation.navigate('CustomFeed', {
+                      navigation.navigate('ProfileCustomFeed', {
                         name: item.data.creator.did,
                         rkey: new AtUri(item.data.uri).rkey,
-                        displayName:
-                          item.data.displayName ??
-                          `${item.data.creator.displayName}'s feed`,
                       })
                     }}
                     style={styles.pinnedItem}>
-- 
cgit 1.4.1


From 998879d6d60b59e65250af395fd6ce389c89189b Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
Date: Wed, 17 May 2023 22:04:01 -0500
Subject: Remove redundant feed-settings page

---
 src/Navigation.tsx                |   4 +-
 src/lib/routes/types.ts           |   1 -
 src/routes.ts                     |   1 -
 src/view/com/feeds/SavedFeeds.tsx |   2 +-
 src/view/index.ts                 |  12 +--
 src/view/screens/PinnedFeeds.tsx  | 179 --------------------------------------
 src/view/screens/SavedFeeds.tsx   | 158 +++++++++++++++------------------
 src/view/screens/Settings.tsx     |  27 +++---
 8 files changed, 94 insertions(+), 290 deletions(-)
 delete mode 100644 src/view/screens/PinnedFeeds.tsx

(limited to 'src/Navigation.tsx')

diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 025020afa..ea36b0f20 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -55,9 +55,8 @@ import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
 import {AppPasswords} from 'view/screens/AppPasswords'
 import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
 import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
+import {SavedFeeds} from 'view/screens/SavedFeeds'
 import {getRoutingInstrumentation} from 'lib/sentry'
-import {SavedFeeds} from './view/screens/SavedFeeds'
-import {PinnedFeeds} from 'view/screens/PinnedFeeds'
 import {bskyTitle} from 'lib/strings/headings'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -189,7 +188,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         options={{title: title('App Passwords')}}
       />
       <Stack.Screen name="SavedFeeds" component={SavedFeeds} />
-      <Stack.Screen name="PinnedFeeds" component={PinnedFeeds} />
     </>
   )
 }
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 52d0e9af2..5c5185602 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -27,7 +27,6 @@ export type CommonNavigatorParams = {
   CopyrightPolicy: undefined
   AppPasswords: undefined
   SavedFeeds: undefined
-  PinnedFeeds: undefined
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
diff --git a/src/routes.ts b/src/routes.ts
index 7501e7abf..c5dc4fb5b 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -21,7 +21,6 @@ export const router = new Router({
   Log: '/sys/log',
   AppPasswords: '/settings/app-passwords',
   SavedFeeds: '/settings/saved-feeds',
-  PinnedFeeds: '/settings/pinned-feeds',
   Support: '/support',
   PrivacyPolicy: '/support/privacy',
   TermsOfService: '/support/tos',
diff --git a/src/view/com/feeds/SavedFeeds.tsx b/src/view/com/feeds/SavedFeeds.tsx
index 06c47d114..2d0057cfb 100644
--- a/src/view/com/feeds/SavedFeeds.tsx
+++ b/src/view/com/feeds/SavedFeeds.tsx
@@ -50,7 +50,7 @@ export const SavedFeeds = observer(
       return (
         <Link
           style={[styles.footerLink, pal.border]}
-          href="/settings/pinned-feeds">
+          href="/settings/saved-feeds">
           <FontAwesomeIcon icon="cog" size={18} color={pal.colors.icon} />
           <Text type="lg-medium" style={pal.textLight}>
             Settings
diff --git a/src/view/index.ts b/src/view/index.ts
index 84fc3f315..f06bdaccc 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -60,14 +60,17 @@ import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
 import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare'
 import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus'
 import {faQuoteLeft} from '@fortawesome/free-solid-svg-icons/faQuoteLeft'
+import {faReply} from '@fortawesome/free-solid-svg-icons/faReply'
+import {faRetweet} from '@fortawesome/free-solid-svg-icons/faRetweet'
+import {faRss} from '@fortawesome/free-solid-svg-icons/faRss'
+import {faSatelliteDish} from '@fortawesome/free-solid-svg-icons/faSatelliteDish'
 import {faShare} from '@fortawesome/free-solid-svg-icons/faShare'
 import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare'
 import {faShield} from '@fortawesome/free-solid-svg-icons/faShield'
 import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus'
 import {faSignal} from '@fortawesome/free-solid-svg-icons/faSignal'
-import {faReply} from '@fortawesome/free-solid-svg-icons/faReply'
-import {faRetweet} from '@fortawesome/free-solid-svg-icons/faRetweet'
-import {faRss} from '@fortawesome/free-solid-svg-icons/faRss'
+import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
+import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
 import {faUser} from '@fortawesome/free-regular-svg-icons/faUser'
 import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers'
 import {faUserCheck} from '@fortawesome/free-solid-svg-icons/faUserCheck'
@@ -75,8 +78,6 @@ import {faUserSlash} from '@fortawesome/free-solid-svg-icons/faUserSlash'
 import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus'
 import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark'
 import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash'
-import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
-import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
 import {faX} from '@fortawesome/free-solid-svg-icons/faX'
 import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
 import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
@@ -148,6 +149,7 @@ export function setup() {
     faReply,
     faRetweet,
     faRss,
+    faSatelliteDish,
     faShare,
     faShareFromSquare,
     faShield,
diff --git a/src/view/screens/PinnedFeeds.tsx b/src/view/screens/PinnedFeeds.tsx
deleted file mode 100644
index a90012093..000000000
--- a/src/view/screens/PinnedFeeds.tsx
+++ /dev/null
@@ -1,179 +0,0 @@
-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} from 'lib/styles'
-import DraggableFlatList, {
-  ShadowDecorator,
-  ScaleDecorator,
-} from 'react-native-draggable-flatlist'
-import {SavedFeedItem} from 'view/com/feeds/SavedFeedItem'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {CustomFeedModel} from 'state/models/feeds/custom-feed'
-
-type Props = NativeStackScreenProps<CommonNavigatorParams, 'PinnedFeeds'>
-
-export const PinnedFeeds = withAuthRequired(
-  observer(({}: Props) => {
-    // hooks for global items
-    const pal = usePalette('default')
-    const rootStore = useStores()
-    const {screen} = useAnalytics()
-
-    // hooks for local
-    const savedFeeds = useMemo(() => rootStore.me.savedFeeds, [rootStore])
-    useFocusEffect(
-      useCallback(() => {
-        screen('SavedFeeds')
-        rootStore.shell.setMinimalShellMode(false)
-        savedFeeds.refresh()
-      }, [screen, rootStore, savedFeeds]),
-    )
-    const _ListEmptyComponent = () => {
-      return (
-        <View
-          style={[
-            pal.border,
-            !isDesktopWeb && s.flex1,
-            pal.viewLight,
-            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!
-          </Text>
-        </View>
-      )
-    }
-    const _ListFooterComponent = () => {
-      return (
-        <View style={styles.footer}>
-          {savedFeeds.isLoading && <ActivityIndicator />}
-        </View>
-      )
-    }
-
-    return (
-      <CenteredView style={[s.flex1]}>
-        <ViewHeader title="Edit My Feeds" showOnDesktop />
-        <DraggableFlatList
-          containerStyle={[!isDesktopWeb && s.flex1]}
-          data={[...savedFeeds.pinned, ...savedFeeds.unpinned]} // make a copy so this FlatList re-renders when pinned changes
-          keyExtractor={item => item.data.uri}
-          refreshing={savedFeeds.isRefreshing}
-          refreshControl={
-            <RefreshControl
-              refreshing={savedFeeds.isRefreshing}
-              onRefresh={() => savedFeeds.refresh()}
-              tintColor={pal.colors.text}
-              titleColor={pal.colors.text}
-            />
-          }
-          renderItem={({item, drag}) => <ListItem item={item} drag={drag} />}
-          initialNumToRender={10}
-          ListFooterComponent={_ListFooterComponent}
-          ListEmptyComponent={_ListEmptyComponent}
-          extraData={savedFeeds.isLoading}
-          onDragEnd={({data}) => savedFeeds.reorderPinnedFeeds(data)}
-          // @ts-ignore our .web version only -prf
-          desktopFixedHeight
-        />
-      </CenteredView>
-    )
-  }),
-)
-
-const ListItem = observer(
-  ({item, drag}: {item: CustomFeedModel; drag: () => void}) => {
-    const pal = usePalette('default')
-    const rootStore = useStores()
-    const savedFeeds = useMemo(() => rootStore.me.savedFeeds, [rootStore])
-    const isPinned = savedFeeds.isPinned(item)
-    return (
-      <ScaleDecorator>
-        <ShadowDecorator>
-          <Pressable
-            accessibilityRole="button"
-            onLongPress={isPinned ? drag : undefined}
-            style={[styles.itemContainer, pal.border]}>
-            {isPinned && isWeb ? (
-              <View style={styles.webArrowButtonsContainer}>
-                <TouchableOpacity
-                  accessibilityRole="button"
-                  onPress={() => {
-                    savedFeeds.movePinnedItem(item, 'up')
-                  }}>
-                  <FontAwesomeIcon
-                    icon="arrow-up"
-                    size={20}
-                    style={[s.mr10, pal.text, styles.webArrowUpButton]}
-                  />
-                </TouchableOpacity>
-                <TouchableOpacity
-                  accessibilityRole="button"
-                  onPress={() => {
-                    savedFeeds.movePinnedItem(item, 'down')
-                  }}>
-                  <FontAwesomeIcon
-                    icon="arrow-down"
-                    size={20}
-                    style={[s.mr10, pal.text]}
-                  />
-                </TouchableOpacity>
-              </View>
-            ) : isPinned ? (
-              <FontAwesomeIcon
-                icon="bars"
-                size={20}
-                color={pal.colors.text}
-                style={s.ml20}
-              />
-            ) : null}
-            <SavedFeedItem item={item} savedFeeds={savedFeeds} showSaveBtn />
-          </Pressable>
-        </ShadowDecorator>
-      </ScaleDecorator>
-    )
-  },
-)
-
-const styles = StyleSheet.create({
-  footer: {
-    paddingVertical: 20,
-  },
-  empty: {
-    paddingHorizontal: 20,
-    paddingVertical: 20,
-    borderRadius: 16,
-    marginHorizontal: 24,
-    marginTop: 10,
-  },
-  itemContainer: {
-    flex: 1,
-    flexDirection: 'row',
-    alignItems: 'center',
-    borderTopWidth: 1,
-  },
-  webArrowButtonsContainer: {
-    flexDirection: 'column',
-    justifyContent: 'space-around',
-  },
-  webArrowUpButton: {marginBottom: 10},
-})
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index c32639889..b1ea27af8 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -4,9 +4,8 @@ import {
   StyleSheet,
   View,
   ActivityIndicator,
-  FlatList,
+  Pressable,
   TouchableOpacity,
-  ScrollView,
 } from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -21,16 +20,18 @@ import {CenteredView} from 'view/com/util/Views'
 import {Text} from 'view/com/util/text/Text'
 import {isDesktopWeb, isWeb} from 'platform/detection'
 import {s} from 'lib/styles'
-import {SavedFeedsModel} from 'state/models/ui/saved-feeds'
-import {Link} from 'view/com/util/Link'
-import {UserAvatar} from 'view/com/util/UserAvatar'
+import DraggableFlatList, {
+  ShadowDecorator,
+  ScaleDecorator,
+} from 'react-native-draggable-flatlist'
 import {SavedFeedItem} from 'view/com/feeds/SavedFeedItem'
-import {AtUri} from '@atproto/api'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {CustomFeedModel} from 'state/models/feeds/custom-feed'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
 
 export const SavedFeeds = withAuthRequired(
-  observer(({navigation}: Props) => {
+  observer(({}: Props) => {
     // hooks for global items
     const pal = usePalette('default')
     const rootStore = useStores()
@@ -55,8 +56,8 @@ export const SavedFeeds = withAuthRequired(
             styles.empty,
           ]}>
           <Text type="lg" style={[pal.text]}>
-            You don't have any saved feeds. To save a feed, click the save
-            button when a custom feed or algorithm shows up.
+            You don't have any pinned feeds. To pin a feed, go back to the Saved
+            Feeds screen and click the pin icon!
           </Text>
         </View>
       )
@@ -71,10 +72,10 @@ export const SavedFeeds = withAuthRequired(
 
     return (
       <CenteredView style={[s.flex1]}>
-        <ViewHeader title="Saved Feeds" showOnDesktop />
-        <FlatList
-          style={[!isDesktopWeb && s.flex1]}
-          data={savedFeeds.feeds}
+        <ViewHeader title="Edit My Feeds" showOnDesktop />
+        <DraggableFlatList
+          containerStyle={[!isDesktopWeb && s.flex1]}
+          data={[...savedFeeds.pinned, ...savedFeeds.unpinned]} // make a copy so this FlatList re-renders when pinned changes
           keyExtractor={item => item.data.uri}
           refreshing={savedFeeds.isRefreshing}
           refreshControl={
@@ -85,19 +86,12 @@ export const SavedFeeds = withAuthRequired(
               titleColor={pal.colors.text}
             />
           }
-          renderItem={({item}) => (
-            <SavedFeedItem item={item} savedFeeds={savedFeeds} />
-          )}
+          renderItem={({item, drag}) => <ListItem item={item} drag={drag} />}
           initialNumToRender={10}
-          ListHeaderComponent={() => (
-            <ListHeaderComponent
-              savedFeeds={savedFeeds}
-              navigation={navigation}
-            />
-          )}
           ListFooterComponent={_ListFooterComponent}
           ListEmptyComponent={_ListEmptyComponent}
           extraData={savedFeeds.isLoading}
+          onDragEnd={({data}) => savedFeeds.reorderPinnedFeeds(data)}
           // @ts-ignore our .web version only -prf
           desktopFixedHeight
         />
@@ -106,64 +100,56 @@ export const SavedFeeds = withAuthRequired(
   }),
 )
 
-const ListHeaderComponent = observer(
-  ({
-    savedFeeds,
-    navigation,
-  }: {
-    savedFeeds: SavedFeedsModel
-    navigation: Props['navigation']
-  }) => {
+const ListItem = observer(
+  ({item, drag}: {item: CustomFeedModel; drag: () => void}) => {
     const pal = usePalette('default')
+    const rootStore = useStores()
+    const savedFeeds = useMemo(() => rootStore.me.savedFeeds, [rootStore])
+    const isPinned = savedFeeds.isPinned(item)
     return (
-      <View style={styles.headerContainer}>
-        {savedFeeds.pinned.length > 0 ? (
-          <View style={styles.pinnedContainer}>
-            <View style={styles.pinnedHeader}>
-              <Text type="lg-bold" style={[pal.text]}>
-                Pinned Feeds
-              </Text>
-              <Link href="/settings/pinned-feeds">
-                <Text style={[styles.editPinned, pal.text]}>Edit</Text>
-              </Link>
-            </View>
-
-            <ScrollView
-              horizontal={true}
-              showsHorizontalScrollIndicator={isWeb}>
-              {savedFeeds.pinned.map(item => {
-                return (
-                  <TouchableOpacity
-                    key={item.data.uri}
-                    accessibilityRole="button"
-                    onPress={() => {
-                      navigation.navigate('ProfileCustomFeed', {
-                        name: item.data.creator.did,
-                        rkey: new AtUri(item.data.uri).rkey,
-                      })
-                    }}
-                    style={styles.pinnedItem}>
-                    <UserAvatar
-                      type="algo"
-                      avatar={item.data.avatar}
-                      size={80}
-                    />
-                    <Text
-                      type="sm-medium"
-                      numberOfLines={1}
-                      style={[pal.text, styles.pinnedItemName]}>
-                      {item.data.displayName ??
-                        `${item.data.creator.displayName}'s feed`}
-                    </Text>
-                  </TouchableOpacity>
-                )
-              })}
-            </ScrollView>
-          </View>
-        ) : null}
-
-        <Text type="lg-bold">All Saved Feeds</Text>
-      </View>
+      <ScaleDecorator>
+        <ShadowDecorator>
+          <Pressable
+            accessibilityRole="button"
+            onLongPress={isPinned ? drag : undefined}
+            style={[styles.itemContainer, pal.border]}>
+            {isPinned && isWeb ? (
+              <View style={styles.webArrowButtonsContainer}>
+                <TouchableOpacity
+                  accessibilityRole="button"
+                  onPress={() => {
+                    savedFeeds.movePinnedItem(item, 'up')
+                  }}>
+                  <FontAwesomeIcon
+                    icon="arrow-up"
+                    size={20}
+                    style={[s.mr10, pal.text, styles.webArrowUpButton]}
+                  />
+                </TouchableOpacity>
+                <TouchableOpacity
+                  accessibilityRole="button"
+                  onPress={() => {
+                    savedFeeds.movePinnedItem(item, 'down')
+                  }}>
+                  <FontAwesomeIcon
+                    icon="arrow-down"
+                    size={20}
+                    style={[s.mr10, pal.text]}
+                  />
+                </TouchableOpacity>
+              </View>
+            ) : isPinned ? (
+              <FontAwesomeIcon
+                icon="bars"
+                size={20}
+                color={pal.colors.text}
+                style={s.ml20}
+              />
+            ) : null}
+            <SavedFeedItem item={item} savedFeeds={savedFeeds} showSaveBtn />
+          </Pressable>
+        </ShadowDecorator>
+      </ScaleDecorator>
     )
   },
 )
@@ -179,15 +165,15 @@ const styles = StyleSheet.create({
     marginHorizontal: 24,
     marginTop: 10,
   },
-  headerContainer: {paddingHorizontal: 18, paddingTop: 18},
-  pinnedContainer: {marginBottom: 18, gap: 18},
-  pinnedHeader: {flexDirection: 'row', justifyContent: 'space-between'},
-  pinnedItem: {
+  itemContainer: {
     flex: 1,
+    flexDirection: 'row',
     alignItems: 'center',
-    marginRight: 18,
-    maxWidth: 100,
+    borderTopWidth: 1,
+  },
+  webArrowButtonsContainer: {
+    flexDirection: 'column',
+    justifyContent: 'space-around',
   },
-  pinnedItemName: {marginTop: 8, textAlign: 'center'},
-  editPinned: {textDecorationLine: 'underline'},
+  webArrowUpButton: {marginBottom: 10},
 })
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index a919f11b0..3ce41f8c0 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -284,38 +284,37 @@ export const SettingsScreen = withAuthRequired(
 
           <View style={styles.spacer20} />
 
+          <Text type="xl-bold" style={[pal.text, styles.heading]}>
+            Advanced
+          </Text>
           <Link
-            testID="bookmarkedAlgosBtn"
+            testID="appPasswordBtn"
             style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
-            accessibilityHint="Custom Algorithms"
-            accessibilityLabel="Opens screen with all bookmarked custom algorithms"
-            href="/settings/saved-feeds">
+            href="/settings/app-passwords">
             <View style={[styles.iconContainer, pal.btn]}>
               <FontAwesomeIcon
-                icon="rss"
+                icon="lock"
                 style={pal.text as FontAwesomeIconStyle}
               />
             </View>
             <Text type="lg" style={pal.text}>
-              Custom Algorithms
+              App passwords
             </Text>
           </Link>
-
-          <Text type="xl-bold" style={[pal.text, styles.heading]}>
-            Advanced
-          </Text>
           <Link
-            testID="appPasswordBtn"
+            testID="savedFeedsBtn"
             style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
-            href="/settings/app-passwords">
+            accessibilityHint="Saved Feeds"
+            accessibilityLabel="Opens screen with all saved feeds"
+            href="/settings/saved-feeds">
             <View style={[styles.iconContainer, pal.btn]}>
               <FontAwesomeIcon
-                icon="lock"
+                icon="satellite-dish"
                 style={pal.text as FontAwesomeIconStyle}
               />
             </View>
             <Text type="lg" style={pal.text}>
-              App passwords
+              Saved Feeds
             </Text>
           </Link>
           <TouchableOpacity
-- 
cgit 1.4.1


From a2c89b47a135d83057b34d0b0b7a1fa7551e0ad2 Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
Date: Wed, 17 May 2023 22:12:14 -0500
Subject: Add custom feed liked by screen

---
 src/Navigation.tsx                     |   9 +-
 src/lib/routes/types.ts                |   3 +-
 src/routes.ts                          |   1 +
 src/view/com/feeds/CustomFeed.tsx      |   2 +-
 src/view/screens/CustomFeed.tsx        | 235 ++++++++++++++++++++++++++
 src/view/screens/CustomFeedLikedBy.tsx |  29 ++++
 src/view/screens/ProfileCustomFeed.tsx | 293 ---------------------------------
 7 files changed, 275 insertions(+), 297 deletions(-)
 create mode 100644 src/view/screens/CustomFeed.tsx
 create mode 100644 src/view/screens/CustomFeedLikedBy.tsx
 delete mode 100644 src/view/screens/ProfileCustomFeed.tsx

(limited to 'src/Navigation.tsx')

diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index ea36b0f20..d84167d63 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -40,7 +40,8 @@ import {SettingsScreen} from './view/screens/Settings'
 import {ProfileScreen} from './view/screens/Profile'
 import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
 import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
-import {ProfileCustomFeed} from './view/screens/ProfileCustomFeed'
+import {CustomFeedScreen} from './view/screens/CustomFeed'
+import {CustomFeedLikedByScreen} from './view/screens/CustomFeedLikedBy'
 import {ProfileListScreen} from './view/screens/ProfileList'
 import {PostThreadScreen} from './view/screens/PostThread'
 import {PostLikedByScreen} from './view/screens/PostLikedBy'
@@ -126,7 +127,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
           title: title(`People followed by @${route.params.name}`),
         })}
       />
-      <Stack.Screen name="ProfileCustomFeed" component={ProfileCustomFeed} />
       <Stack.Screen
         name="ProfileList"
         component={ProfileListScreen}
@@ -147,6 +147,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         component={PostRepostedByScreen}
         options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
       />
+      <Stack.Screen name="CustomFeed" component={CustomFeedScreen} />
+      <Stack.Screen
+        name="CustomFeedLikedBy"
+        component={CustomFeedLikedByScreen}
+      />
       <Stack.Screen
         name="Debug"
         component={DebugScreen}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 5c5185602..b714c10c0 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -13,11 +13,12 @@ export type CommonNavigatorParams = {
   Profile: {name: string; hideBackButton?: boolean}
   ProfileFollowers: {name: string}
   ProfileFollows: {name: string}
-  ProfileCustomFeed: {name: string; rkey: string}
   ProfileList: {name: string; rkey: string}
   PostThread: {name: string; rkey: string}
   PostLikedBy: {name: string; rkey: string}
   PostRepostedBy: {name: string; rkey: string}
+  CustomFeed: {name: string; rkey: string}
+  CustomFeedLikedBy: {name: string; rkey: string}
   Debug: undefined
   Log: undefined
   Support: undefined
diff --git a/src/routes.ts b/src/routes.ts
index c5dc4fb5b..9e3e3f7ea 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -17,6 +17,7 @@ export const router = new Router({
   PostLikedBy: '/profile/:name/post/:rkey/liked-by',
   PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
   CustomFeed: '/profile/:name/feed/:rkey',
+  CustomFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
   Debug: '/sys/debug',
   Log: '/sys/log',
   AppPasswords: '/settings/app-passwords',
diff --git a/src/view/com/feeds/CustomFeed.tsx b/src/view/com/feeds/CustomFeed.tsx
index 5201ca848..911c33da4 100644
--- a/src/view/com/feeds/CustomFeed.tsx
+++ b/src/view/com/feeds/CustomFeed.tsx
@@ -61,7 +61,7 @@ export const CustomFeed = observer(
         accessibilityRole="button"
         style={[styles.container, pal.border, style]}
         onPress={() => {
-          navigation.navigate('ProfileCustomFeed', {
+          navigation.navigate('CustomFeed', {
             name: item.data.creator.did,
             rkey: new AtUri(item.data.uri).rkey,
           })
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx
new file mode 100644
index 000000000..9f7f81691
--- /dev/null
+++ b/src/view/screens/CustomFeed.tsx
@@ -0,0 +1,235 @@
+import React, {useMemo, useRef} from 'react'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+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'
+
+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 onToggleSaved = React.useCallback(async () => {
+      try {
+        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 () => {
+      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 renderHeaderBtns = React.useCallback(() => {
+      return (
+        <View style={styles.headerBtns}>
+          <Button
+            type="default"
+            testID="toggleLikeBtn"
+            accessibilityLabel="Like this feed"
+            accessibilityHint=""
+            onPress={onToggleLiked}>
+            {currentFeed?.isLiked ? (
+              <HeartIconSolid size={18} style={styles.liked} />
+            ) : (
+              <HeartIcon strokeWidth={3} size={18} style={pal.textLight} />
+            )}
+          </Button>
+          <Button
+            type={currentFeed?.isSaved ? 'default' : 'inverted'}
+            onPress={onToggleSaved}
+            accessibilityLabel={
+              currentFeed?.isSaved ? 'Remove from my feeds' : 'Add to my feeds'
+            }
+            accessibilityHint=""
+            label={
+              currentFeed?.isSaved ? 'Remove from My Feeds' : 'Add to My Feeds'
+            }
+          />
+        </View>
+      )
+    }, [
+      pal,
+      currentFeed?.isSaved,
+      currentFeed?.isLiked,
+      onToggleSaved,
+      onToggleLiked,
+    ])
+
+    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}`}
+                    />
+                  )}
+                </Text>
+              )}
+            </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}
+            <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',
+              )}`}
+            />
+            {isDesktopWeb && (
+              <View style={styles.headerBtns}>
+                <Button
+                  type={currentFeed?.isSaved ? 'default' : 'inverted'}
+                  onPress={onToggleSaved}
+                  accessibilityLabel={
+                    currentFeed?.isSaved ? 'Unsave this feed' : 'Save this feed'
+                  }
+                  accessibilityHint=""
+                  label={currentFeed?.isSaved ? 'Unsave' : 'Save'}
+                />
+
+                <Button type="default" onPress={onToggleLiked}>
+                  {currentFeed?.isLiked ? (
+                    <HeartIconSolid size={18} style={styles.liked} />
+                  ) : (
+                    <HeartIcon strokeWidth={3} size={18} style={pal.icon} />
+                  )}
+                </Button>
+              </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>
+        </>
+      )
+    }, [store.me.did, pal, currentFeed, onToggleLiked, onToggleSaved])
+
+    return (
+      <View style={s.hContentRegion}>
+        <ViewHeader title="" renderButton={renderHeaderBtns} />
+        <Feed
+          scrollElRef={scrollElRef}
+          feed={algoFeed}
+          ListHeaderComponent={renderListHeaderComponent}
+          extraData={uri}
+        />
+      </View>
+    )
+  }),
+)
+
+const styles = StyleSheet.create({
+  headerBtns: {
+    flexDirection: 'row',
+    gap: 8,
+  },
+  header: {
+    flexDirection: 'row',
+    gap: 12,
+    paddingHorizontal: 16,
+    paddingTop: 12,
+    paddingBottom: 16,
+    borderTopWidth: 1,
+  },
+  headerDetails: {
+    paddingHorizontal: 16,
+    paddingBottom: 16,
+  },
+  fakeSelector: {
+    flexDirection: 'row',
+    paddingHorizontal: isDesktopWeb ? 16 : 6,
+  },
+  fakeSelectorItem: {
+    paddingHorizontal: 12,
+    paddingBottom: 8,
+    borderBottomWidth: 3,
+  },
+  liked: {
+    color: colors.red3,
+  },
+})
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/ProfileCustomFeed.tsx b/src/view/screens/ProfileCustomFeed.tsx
deleted file mode 100644
index 681798308..000000000
--- a/src/view/screens/ProfileCustomFeed.tsx
+++ /dev/null
@@ -1,293 +0,0 @@
-import React, {useMemo, useRef} from 'react'
-import {NativeStackScreenProps} from '@react-navigation/native-stack'
-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'
-
-type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileCustomFeed'>
-export const ProfileCustomFeed = 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 onToggleSaved = React.useCallback(async () => {
-      try {
-        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 () => {
-      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 renderHeaderBtns = React.useCallback(() => {
-      return (
-        <View style={styles.headerBtns}>
-          <Button
-            type="default"
-            testID="toggleLikeBtn"
-            accessibilityLabel="Like this feed"
-            accessibilityHint=""
-            onPress={onToggleLiked}>
-            {currentFeed?.isLiked ? (
-              <HeartIconSolid size={18} style={styles.liked} />
-            ) : (
-              <HeartIcon strokeWidth={3} size={18} style={pal.textLight} />
-            )}
-          </Button>
-          <Button
-            type={currentFeed?.isSaved ? 'default' : 'inverted'}
-            onPress={onToggleSaved}
-            accessibilityLabel={
-              currentFeed?.isSaved ? 'Remove from my feeds' : 'Add to my feeds'
-            }
-            accessibilityHint=""
-            label={
-              currentFeed?.isSaved ? 'Remove from My Feeds' : 'Add to My Feeds'
-            }
-          />
-        </View>
-      )
-    }, [
-      pal,
-      currentFeed?.isSaved,
-      currentFeed?.isLiked,
-      onToggleSaved,
-      onToggleLiked,
-    ])
-
-    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}`}
-                    />
-                  )}
-                </Text>
-              )}
-            </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}
-            <Text type="md-medium" style={pal.textLight}>
-              Liked by {currentFeed?.data.likeCount}{' '}
-              {pluralize(currentFeed?.data.likeCount || 0, 'user')}
-            </Text>
-            {isDesktopWeb && (
-              <View style={styles.headerBtns}>
-                <Button
-                  type={currentFeed?.isSaved ? 'default' : 'inverted'}
-                  onPress={onToggleSaved}
-                  accessibilityLabel={
-                    currentFeed?.isSaved ? 'Unsave this feed' : 'Save this feed'
-                  }
-                  accessibilityHint=""
-                  label={currentFeed?.isSaved ? 'Unsave' : 'Save'}
-                />
-
-                <Button type="default" onPress={onToggleLiked}>
-                  {currentFeed?.isLiked ? (
-                    <HeartIconSolid size={18} style={styles.liked} />
-                  ) : (
-                    <HeartIcon strokeWidth={3} size={18} style={pal.icon} />
-                  )}
-                </Button>
-              </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>
-        </>
-      )
-    }, [store.me.did, pal, currentFeed, onToggleLiked, onToggleSaved])
-
-    return (
-      <View style={s.hContentRegion}>
-        <ViewHeader title="" renderButton={renderHeaderBtns} />
-        <Feed
-          scrollElRef={scrollElRef}
-          feed={algoFeed}
-          ListHeaderComponent={renderListHeaderComponent}
-          extraData={uri}
-        />
-      </View>
-    )
-  }),
-)
-
-/*
-
-        <View style={[styles.headerContainer]}>
-          <View style={[styles.header]}>
-            <View style={styles.avatarContainer}>
-              <UserAvatar
-                type="algo"
-                size={28}
-                avatar={currentFeed?.data.avatar}
-              />
-              <Link href={`/profile/${currentFeed?.data.creator.handle}`}>
-                <Text style={[pal.textLight]}>
-                  @{currentFeed?.data.creator.handle}
-                </Text>
-              </Link>
-            </View>
-            <Text style={[pal.text]}>{currentFeed?.data.description}</Text>
-          </View>
-
-          <View style={[styles.buttonsContainer]}>
-          </View>
-        </View>
-        */
-
-const styles = StyleSheet.create({
-  headerBtns: {
-    flexDirection: 'row',
-    gap: 8,
-  },
-  header: {
-    flexDirection: 'row',
-    gap: 12,
-    paddingHorizontal: 16,
-    paddingTop: 12,
-    paddingBottom: 16,
-    borderTopWidth: 1,
-  },
-  headerDetails: {
-    paddingHorizontal: 16,
-    paddingBottom: 16,
-  },
-  fakeSelector: {
-    flexDirection: 'row',
-    paddingHorizontal: isDesktopWeb ? 16 : 6,
-  },
-  fakeSelectorItem: {
-    paddingHorizontal: 12,
-    paddingBottom: 8,
-    borderBottomWidth: 3,
-  },
-  liked: {
-    color: colors.red3,
-  },
-
-  /*  headerContainer: {
-    alignItems: 'center',
-    justifyContent: 'center',
-    gap: 8,
-    marginBottom: 12,
-  },
-  header: {
-    alignItems: 'center',
-    gap: 4,
-  },
-  avatarContainer: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    gap: 8,
-  },
-  buttonsContainer: {
-    flexDirection: 'row',
-    gap: 8,
-  },
-  saveButton: {
-    minWidth: 100,
-    alignItems: 'center',
-  },
-  liked: {
-    color: colors.red3,
-  },
-  notLiked: {
-    color: colors.gray3,
-  },
-  likeButton: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    paddingVertical: 4,
-    paddingHorizontal: 8,
-    borderRadius: 24,
-    gap: 4,
-  },*/
-})
-- 
cgit 1.4.1


From 5a20e0fafa2bdbfd57a69da42448a687c7d812c1 Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
Date: Wed, 17 May 2023 23:33:59 -0500
Subject: Add web titles

---
 src/Navigation.tsx | 17 +++++++++++++++--
 1 file changed, 15 insertions(+), 2 deletions(-)

(limited to 'src/Navigation.tsx')

diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index d84167d63..46189c5f0 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -147,10 +147,19 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         component={PostRepostedByScreen}
         options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
       />
-      <Stack.Screen name="CustomFeed" component={CustomFeedScreen} />
+      <Stack.Screen
+        name="CustomFeed"
+        component={CustomFeedScreen}
+        options={({route}) => ({
+          title: title(`Custom feed by @${route.params.name}`),
+        })}
+      />
       <Stack.Screen
         name="CustomFeedLikedBy"
         component={CustomFeedLikedByScreen}
+        options={({route}) => ({
+          title: title(`Custom feed by @${route.params.name}`),
+        })}
       />
       <Stack.Screen
         name="Debug"
@@ -192,7 +201,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         component={AppPasswords}
         options={{title: title('App Passwords')}}
       />
-      <Stack.Screen name="SavedFeeds" component={SavedFeeds} />
+      <Stack.Screen
+        name="SavedFeeds"
+        component={SavedFeeds}
+        options={{title: title('Edit My Feeds')}}
+      />
     </>
   )
 }
-- 
cgit 1.4.1


From d88c27a41995c181a38c01248fe01f853ba83366 Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
Date: Wed, 17 May 2023 23:36:36 -0500
Subject: Improve web titles

---
 src/Navigation.tsx              | 8 ++------
 src/view/screens/CustomFeed.tsx | 3 +++
 2 files changed, 5 insertions(+), 6 deletions(-)

(limited to 'src/Navigation.tsx')

diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 46189c5f0..ff7a5f5c2 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -150,16 +150,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
       <Stack.Screen
         name="CustomFeed"
         component={CustomFeedScreen}
-        options={({route}) => ({
-          title: title(`Custom feed by @${route.params.name}`),
-        })}
+        options={{title: title('Feed')}}
       />
       <Stack.Screen
         name="CustomFeedLikedBy"
         component={CustomFeedLikedByScreen}
-        options={({route}) => ({
-          title: title(`Custom feed by @${route.params.name}`),
-        })}
+        options={{title: title('Liked by')}}
       />
       <Stack.Screen
         name="Debug"
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx
index 9f7f81691..76b26dcf7 100644
--- a/src/view/screens/CustomFeed.tsx
+++ b/src/view/screens/CustomFeed.tsx
@@ -20,6 +20,7 @@ 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'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'>
 export const CustomFeedScreen = withAuthRequired(
@@ -41,6 +42,8 @@ export const CustomFeedScreen = withAuthRequired(
       return feed
     }, [store, uri])
 
+    useSetTitle(currentFeed?.displayName)
+
     const onToggleSaved = React.useCallback(async () => {
       try {
         if (currentFeed?.isSaved) {
-- 
cgit 1.4.1


From dfb39e7c4fcaff3effcc82b412191177fdfdaf22 Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
Date: Wed, 24 May 2023 22:09:39 -0500
Subject: Add feed discovery page

---
 bskyweb/cmd/bskyweb/server.go       |   1 +
 package.json                        |   2 +-
 src/Navigation.tsx                  |   6 ++
 src/lib/routes/types.ts             |   1 +
 src/routes.ts                       |   1 +
 src/state/models/discovery/feeds.ts |  97 ++++++++++++++++++++++++++++++++
 src/view/com/feeds/SavedFeeds.tsx   |  41 +++++++++-----
 src/view/screens/CustomFeed.tsx     |   6 +-
 src/view/screens/DiscoverFeeds.tsx  | 109 ++++++++++++++++++++++++++++++++++++
 yarn.lock                           |   8 +--
 10 files changed, 252 insertions(+), 20 deletions(-)
 create mode 100644 src/state/models/discovery/feeds.ts
 create mode 100644 src/view/screens/DiscoverFeeds.tsx

(limited to 'src/Navigation.tsx')

diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 07df85146..462740f54 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -105,6 +105,7 @@ func serve(cctx *cli.Context) error {
 
 	// generic routes
 	e.GET("/search", server.WebGeneric)
+	e.GET("/search/feeds", server.WebGeneric)
 	e.GET("/notifications", server.WebGeneric)
 	e.GET("/moderation", server.WebGeneric)
 	e.GET("/moderation/mute-lists", server.WebGeneric)
diff --git a/package.json b/package.json
index 5c30a0cae..253c3b782 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
     "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
   },
   "dependencies": {
-    "@atproto/api": "0.3.7",
+    "@atproto/api": "0.3.8",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
     "@braintree/sanitize-url": "^6.0.2",
     "@expo/webpack-config": "^18.0.1",
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index ff7a5f5c2..0664ac526 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -35,6 +35,7 @@ import {SearchScreen} from './view/screens/Search'
 import {NotificationsScreen} from './view/screens/Notifications'
 import {ModerationScreen} from './view/screens/Moderation'
 import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists'
+import {DiscoverFeedsScreen} from 'view/screens/DiscoverFeeds'
 import {NotFoundScreen} from './view/screens/NotFound'
 import {SettingsScreen} from './view/screens/Settings'
 import {ProfileScreen} from './view/screens/Profile'
@@ -103,6 +104,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         component={ModerationBlockedAccounts}
         options={{title: title('Blocked Accounts')}}
       />
+      <Stack.Screen
+        name="DiscoverFeeds"
+        component={DiscoverFeedsScreen}
+        options={{title: title('Discover Feeds')}}
+      />
       <Stack.Screen
         name="Settings"
         component={SettingsScreen}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index b714c10c0..5dca3cc3f 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -9,6 +9,7 @@ export type CommonNavigatorParams = {
   ModerationMuteLists: undefined
   ModerationMutedAccounts: undefined
   ModerationBlockedAccounts: undefined
+  DiscoverFeeds: undefined
   Settings: undefined
   Profile: {name: string; hideBackButton?: boolean}
   ProfileFollowers: {name: string}
diff --git a/src/routes.ts b/src/routes.ts
index 9e3e3f7ea..1c3d91187 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -3,6 +3,7 @@ import {Router} from 'lib/routes/router'
 export const router = new Router({
   Home: '/',
   Search: '/search',
+  DiscoverFeeds: '/search/feeds',
   Notifications: '/notifications',
   Settings: '/settings',
   Moderation: '/moderation',
diff --git a/src/state/models/discovery/feeds.ts b/src/state/models/discovery/feeds.ts
new file mode 100644
index 000000000..26a8d650c
--- /dev/null
+++ b/src/state/models/discovery/feeds.ts
@@ -0,0 +1,97 @@
+import {makeAutoObservable} from 'mobx'
+import {AppBskyUnspeccedGetPopularFeedGenerators} 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'
+
+export class FeedsDiscoveryModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+
+  // data
+  feeds: CustomFeedModel[] = []
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasMore() {
+    return false
+  }
+
+  get hasContent() {
+    return this.feeds.length > 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  // public api
+  // =
+
+  refresh = bundleAsync(async () => {
+    this._xLoading()
+    try {
+      const res =
+        await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators(
+          {},
+        )
+      this._replaceAll(res)
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
+
+  clear() {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = false
+    this.error = ''
+    this.feeds = []
+  }
+
+  // state transitions
+  // =
+
+  _xLoading() {
+    this.isLoading = true
+    this.isRefreshing = true
+    this.error = ''
+  }
+
+  _xIdle(err?: any) {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = true
+    this.error = cleanError(err)
+    if (err) {
+      this.rootStore.log.error('Failed to fetch popular feeds', err)
+    }
+  }
+
+  // helper functions
+  // =
+
+  _replaceAll(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
+    this.feeds = []
+    for (const f of res.data.feeds) {
+      this.feeds.push(new CustomFeedModel(this.rootStore, f))
+    }
+  }
+}
diff --git a/src/view/com/feeds/SavedFeeds.tsx b/src/view/com/feeds/SavedFeeds.tsx
index e92e741da..610562c9d 100644
--- a/src/view/com/feeds/SavedFeeds.tsx
+++ b/src/view/com/feeds/SavedFeeds.tsx
@@ -53,14 +53,28 @@ export const SavedFeeds = observer(
     const renderListFooterComponent = useCallback(() => {
       return (
         <>
-          <Link
-            style={[styles.footerLink, pal.border]}
-            href="/settings/saved-feeds">
-            <FontAwesomeIcon icon="cog" size={18} color={pal.colors.icon} />
-            <Text type="lg-medium" style={pal.textLight}>
-              Change Order
-            </Text>
-          </Link>
+          <View style={[styles.footerLinks, pal.border]}>
+            <Link style={[styles.footerLink, pal.border]} href="/search/feeds">
+              <FontAwesomeIcon
+                icon="search"
+                size={18}
+                color={pal.colors.icon}
+              />
+              <Text type="lg-medium" style={pal.textLight}>
+                Discover new feeds
+              </Text>
+            </Link>
+            {!store.me.savedFeeds.isEmpty && (
+              <Link
+                style={[styles.footerLink, pal.border]}
+                href="/settings/saved-feeds">
+                <FontAwesomeIcon icon="cog" size={18} color={pal.colors.icon} />
+                <Text type="lg-medium" style={pal.textLight}>
+                  Change Order
+                </Text>
+              </Link>
+            )}
+          </View>
           <View
             style={[
               pal.border,
@@ -82,7 +96,7 @@ export const SavedFeeds = observer(
           </View>
         </>
       )
-    }, [pal])
+    }, [pal, store.me.savedFeeds.isEmpty])
 
     const renderItem = useCallback(
       ({item}) => <CustomFeed key={item.data.uri} item={item} />,
@@ -118,14 +132,16 @@ export const SavedFeeds = observer(
 )
 
 const styles = StyleSheet.create({
+  footerLinks: {
+    marginTop: 8,
+    borderBottomWidth: 1,
+  },
   footerLink: {
     flexDirection: 'row',
     borderTopWidth: 1,
-    borderBottomWidth: 1,
     paddingHorizontal: 26,
     paddingVertical: 18,
     gap: 18,
-    marginTop: 8,
   },
   empty: {
     paddingHorizontal: 18,
@@ -134,7 +150,4 @@ const styles = StyleSheet.create({
     marginHorizontal: 18,
     marginTop: 10,
   },
-  feedItem: {
-    borderTopWidth: 1,
-  },
 })
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx
index 0690a17d8..49798d758 100644
--- a/src/view/screens/CustomFeed.tsx
+++ b/src/view/screens/CustomFeed.tsx
@@ -220,7 +220,7 @@ export const CustomFeedScreen = withAuthRequired(
                 </Text>
               )}
               {isDesktopWeb && (
-                <View style={styles.headerBtns}>
+                <View style={[styles.headerBtns, styles.headerBtnsDesktop]}>
                   <Button
                     type={currentFeed?.isSaved ? 'default' : 'inverted'}
                     onPress={onToggleSaved}
@@ -366,6 +366,10 @@ const styles = StyleSheet.create({
     flexDirection: 'row',
     alignItems: 'center',
   },
+  headerBtnsDesktop: {
+    marginTop: 8,
+    gap: 4,
+  },
   headerAddBtn: {
     flexDirection: 'row',
     alignItems: 'center',
diff --git a/src/view/screens/DiscoverFeeds.tsx b/src/view/screens/DiscoverFeeds.tsx
new file mode 100644
index 000000000..82a37942f
--- /dev/null
+++ b/src/view/screens/DiscoverFeeds.tsx
@@ -0,0 +1,109 @@
+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}
+          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,
+    paddingBottom: isDesktopWeb ? 0 : 100,
+  },
+  containerDesktop: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
+  empty: {
+    paddingHorizontal: 18,
+    paddingVertical: 16,
+    borderRadius: 8,
+    marginHorizontal: 18,
+    marginTop: 10,
+  },
+})
diff --git a/yarn.lock b/yarn.lock
index d42660eda..f20c29dc3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -40,10 +40,10 @@
     tlds "^1.234.0"
     typed-emitter "^2.1.0"
 
-"@atproto/api@0.3.7":
-  version "0.3.7"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.3.7.tgz#5cc4b0ccc5c6690eb0e5a3ae138a84ce20697e2f"
-  integrity sha512-JHN3rHNGro4AaJWU64hsmpTUzd2+FbfMBiDkqyBmoKtj972ueBJeH8tz6WdnPcsIRfCj1kRthKFj2yJwgt6aSQ==
+"@atproto/api@0.3.8":
+  version "0.3.8"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.3.8.tgz#3fc0ebd092cc212c2d0b31a600fe1945a02f9cf7"
+  integrity sha512-7qaIZGEP5J9FW4z8bXezzAmLRzHSXXHo6bWP9Jyu5MLp8tYt9vG6yR2N0QA7GvO0xSYqP87Q5vblPjYXGqtDKg==
   dependencies:
     "@atproto/common-web" "*"
     "@atproto/uri" "*"
-- 
cgit 1.4.1


From 257686f3603e800e355850a23b3a4011e5558aeb Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
Date: Thu, 25 May 2023 20:02:37 -0500
Subject: Add feeds tab

---
 bskyweb/cmd/bskyweb/server.go            |   1 +
 src/Navigation.tsx                       |  28 +++-
 src/lib/hooks/useNavigationTabState.ts   |   4 +-
 src/lib/routes/types.ts                  |   7 +
 src/routes.ts                            |   1 +
 src/state/models/feeds/multi-feed.ts     | 216 +++++++++++++++++++++++++
 src/state/models/feeds/post.ts           | 265 ++++++++++++++++++++++++++++++
 src/state/models/feeds/posts.ts          | 270 +------------------------------
 src/view/com/pager/FeedsTabBarMobile.tsx |   8 +-
 src/view/com/pager/TabBar.tsx            |   2 +-
 src/view/com/posts/Feed.tsx              |   2 -
 src/view/com/posts/MultiFeed.tsx         | 230 ++++++++++++++++++++++++++
 src/view/screens/Feeds.tsx               | 125 ++++++++++++++
 src/view/screens/SavedFeeds.tsx          |   6 +-
 src/view/shell/Drawer.tsx                |  30 ++--
 src/view/shell/bottom-bar/BottomBar.tsx  |  30 +++-
 src/view/shell/desktop/LeftNav.tsx       |   2 +-
 17 files changed, 937 insertions(+), 290 deletions(-)
 create mode 100644 src/state/models/feeds/multi-feed.ts
 create mode 100644 src/state/models/feeds/post.ts
 create mode 100644 src/view/com/posts/MultiFeed.tsx
 create mode 100644 src/view/screens/Feeds.tsx

(limited to 'src/Navigation.tsx')

diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 462740f54..85fffd4b8 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -106,6 +106,7 @@ func serve(cctx *cli.Context) error {
 	// generic routes
 	e.GET("/search", server.WebGeneric)
 	e.GET("/search/feeds", server.WebGeneric)
+	e.GET("/feeds", server.WebGeneric)
 	e.GET("/notifications", server.WebGeneric)
 	e.GET("/moderation", server.WebGeneric)
 	e.GET("/moderation/mute-lists", server.WebGeneric)
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 0664ac526..7da77b877 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -14,6 +14,7 @@ import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'
 import {
   HomeTabNavigatorParams,
   SearchTabNavigatorParams,
+  FeedsTabNavigatorParams,
   NotificationsTabNavigatorParams,
   FlatNavigatorParams,
   AllNavigatorParams,
@@ -32,6 +33,7 @@ import {useStores} from './state'
 
 import {HomeScreen} from './view/screens/Home'
 import {SearchScreen} from './view/screens/Search'
+import {FeedsScreen} from './view/screens/Feeds'
 import {NotificationsScreen} from './view/screens/Notifications'
 import {ModerationScreen} from './view/screens/Moderation'
 import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists'
@@ -65,6 +67,7 @@ const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
 
 const HomeTab = createNativeStackNavigator<HomeTabNavigatorParams>()
 const SearchTab = createNativeStackNavigator<SearchTabNavigatorParams>()
+const FeedsTab = createNativeStackNavigator<FeedsTabNavigatorParams>()
 const NotificationsTab =
   createNativeStackNavigator<NotificationsTabNavigatorParams>()
 const MyProfileTab = createNativeStackNavigator<MyProfileTabNavigatorParams>()
@@ -225,11 +228,12 @@ function TabsNavigator() {
       screenOptions={{headerShown: false}}
       tabBar={tabBar}>
       <Tab.Screen name="HomeTab" component={HomeTabNavigator} />
+      <Tab.Screen name="SearchTab" component={SearchTabNavigator} />
+      <Tab.Screen name="FeedsTab" component={FeedsTabNavigator} />
       <Tab.Screen
         name="NotificationsTab"
         component={NotificationsTabNavigator}
       />
-      <Tab.Screen name="SearchTab" component={SearchTabNavigator} />
       <Tab.Screen name="MyProfileTab" component={MyProfileTabNavigator} />
     </Tab.Navigator>
   )
@@ -269,6 +273,23 @@ function SearchTabNavigator() {
   )
 }
 
+function FeedsTabNavigator() {
+  const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
+  return (
+    <FeedsTab.Navigator
+      screenOptions={{
+        gestureEnabled: true,
+        fullScreenGestureEnabled: true,
+        headerShown: false,
+        animationDuration: 250,
+        contentStyle,
+      }}>
+      <FeedsTab.Screen name="Feeds" component={FeedsScreen} />
+      {commonScreens(FeedsTab as typeof HomeTab)}
+    </FeedsTab.Navigator>
+  )
+}
+
 function NotificationsTabNavigator() {
   const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
   return (
@@ -342,6 +363,11 @@ const FlatNavigator = observer(() => {
         component={SearchScreen}
         options={{title: title('Search')}}
       />
+      <Flat.Screen
+        name="Feeds"
+        component={FeedsScreen}
+        options={{title: title('Feeds')}}
+      />
       <Flat.Screen
         name="Notifications"
         component={NotificationsScreen}
diff --git a/src/lib/hooks/useNavigationTabState.ts b/src/lib/hooks/useNavigationTabState.ts
index fb3662152..3a05fe524 100644
--- a/src/lib/hooks/useNavigationTabState.ts
+++ b/src/lib/hooks/useNavigationTabState.ts
@@ -6,14 +6,16 @@ export function useNavigationTabState() {
     const res = {
       isAtHome: getTabState(state, 'Home') !== TabState.Outside,
       isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
+      isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside,
       isAtNotifications:
         getTabState(state, 'Notifications') !== TabState.Outside,
       isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
     }
     if (
       !res.isAtHome &&
-      !res.isAtNotifications &&
       !res.isAtSearch &&
+      !res.isAtFeeds &&
+      !res.isAtNotifications &&
       !res.isAtMyProfile
     ) {
       // HACK for some reason useNavigationState will give us pre-hydration results
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 5dca3cc3f..60c9e3f4b 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -34,6 +34,7 @@ export type CommonNavigatorParams = {
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
   HomeTab: undefined
   SearchTab: undefined
+  FeedsTab: undefined
   NotificationsTab: undefined
   MyProfileTab: undefined
 }
@@ -46,6 +47,10 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & {
   Search: {q?: string}
 }
 
+export type FeedsTabNavigatorParams = CommonNavigatorParams & {
+  Feeds: undefined
+}
+
 export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
   Notifications: undefined
 }
@@ -65,6 +70,8 @@ export type AllNavigatorParams = CommonNavigatorParams & {
   Home: undefined
   SearchTab: undefined
   Search: {q?: string}
+  FeedsTab: undefined
+  Feeds: undefined
   NotificationsTab: undefined
   Notifications: undefined
   MyProfileTab: undefined
diff --git a/src/routes.ts b/src/routes.ts
index 1c3d91187..54faba22d 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -3,6 +3,7 @@ import {Router} from 'lib/routes/router'
 export const router = new Router({
   Home: '/',
   Search: '/search',
+  Feeds: '/feeds',
   DiscoverFeeds: '/search/feeds',
   Notifications: '/notifications',
   Settings: '/settings',
diff --git a/src/state/models/feeds/multi-feed.ts b/src/state/models/feeds/multi-feed.ts
new file mode 100644
index 000000000..3c13041c6
--- /dev/null
+++ b/src/state/models/feeds/multi-feed.ts
@@ -0,0 +1,216 @@
+import {makeAutoObservable, runInAction} from 'mobx'
+import {AtUri} from '@atproto/api'
+import {bundleAsync} from 'lib/async/bundle'
+import {RootStoreModel} from '../root-store'
+import {CustomFeedModel} from './custom-feed'
+import {PostsFeedModel} from './posts'
+import {PostsFeedSliceModel} from './post'
+
+const FEED_PAGE_SIZE = 5
+const FEEDS_PAGE_SIZE = 3
+
+export type MultiFeedItem =
+  | {
+      _reactKey: string
+      type: 'header'
+    }
+  | {
+      _reactKey: string
+      type: 'feed-header'
+      avatar: string | undefined
+      title: string
+    }
+  | {
+      _reactKey: string
+      type: 'feed-slice'
+      slice: PostsFeedSliceModel
+    }
+  | {
+      _reactKey: string
+      type: 'feed-loading'
+    }
+  | {
+      _reactKey: string
+      type: 'feed-error'
+      error: string
+    }
+  | {
+      _reactKey: string
+      type: 'feed-footer'
+      title: string
+      uri: string
+    }
+  | {
+      _reactKey: string
+      type: 'footer'
+    }
+
+export class PostsMultiFeedModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  hasMore = true
+
+  // data
+  feedInfos: CustomFeedModel[] = []
+  feeds: PostsFeedModel[] = []
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(this, {rootStore: false}, {autoBind: true})
+  }
+
+  get hasContent() {
+    return this.feeds.length !== 0
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  get items() {
+    const items: MultiFeedItem[] = [{_reactKey: '__header__', type: 'header'}]
+    for (let i = 0; i < this.feedInfos.length; i++) {
+      if (!this.feeds[i]) {
+        break
+      }
+      const feed = this.feeds[i]
+      const feedInfo = this.feedInfos[i]
+      const urip = new AtUri(feedInfo.uri)
+      items.push({
+        _reactKey: `__feed_header_${i}__`,
+        type: 'feed-header',
+        avatar: feedInfo.data.avatar,
+        title: feedInfo.displayName,
+      })
+      if (feed.isLoading) {
+        items.push({
+          _reactKey: `__feed_loading_${i}__`,
+          type: 'feed-loading',
+        })
+      } else if (feed.hasError) {
+        items.push({
+          _reactKey: `__feed_error_${i}__`,
+          type: 'feed-error',
+          error: feed.error,
+        })
+      } else {
+        for (let j = 0; j < feed.slices.length; j++) {
+          items.push({
+            _reactKey: `__feed_slice_${i}_${j}__`,
+            type: 'feed-slice',
+            slice: feed.slices[j],
+          })
+        }
+      }
+      items.push({
+        _reactKey: `__feed_footer_${i}__`,
+        type: 'feed-footer',
+        title: feedInfo.displayName,
+        uri: `/profile/${feedInfo.data.creator.did}/feed/${urip.rkey}`,
+      })
+    }
+    if (!this.hasMore) {
+      items.push({_reactKey: '__footer__', type: 'footer'})
+    }
+    return items
+  }
+
+  // public api
+  // =
+
+  /**
+   * Nuke all data
+   */
+  clear() {
+    this.rootStore.log.debug('MultiFeedModel:clear')
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = false
+    this.hasMore = true
+    this.feeds = []
+  }
+
+  /**
+   * Register any event listeners. Returns a cleanup function.
+   */
+  registerListeners() {
+    const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this))
+    return () => sub.remove()
+  }
+
+  /**
+   * Reset and load
+   */
+  async refresh() {
+    this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds
+    await this.loadMore(true)
+  }
+
+  /**
+   * Load more posts to the end of the feed
+   */
+  loadMore = bundleAsync(async (isRefreshing: boolean = false) => {
+    if (!isRefreshing && !this.hasMore) {
+      return
+    }
+    if (isRefreshing) {
+      this.isRefreshing = true // set optimistically for UI
+      this.feeds = []
+    }
+    this._xLoading(isRefreshing)
+    const start = this.feeds.length
+    const newFeeds: PostsFeedModel[] = []
+    for (
+      let i = start;
+      i < start + FEEDS_PAGE_SIZE && i < this.feedInfos.length;
+      i++
+    ) {
+      const feed = new PostsFeedModel(this.rootStore, 'custom', {
+        feed: this.feedInfos[i].uri,
+      })
+      feed.pageSize = FEED_PAGE_SIZE
+      await feed.setup()
+      newFeeds.push(feed)
+    }
+    runInAction(() => {
+      this.feeds = this.feeds.concat(newFeeds)
+      this.hasMore = this.feeds.length < this.feedInfos.length
+    })
+    this._xIdle()
+  })
+
+  /**
+   * Attempt to load more again after a failure
+   */
+  async retryLoadMore() {
+    this.hasMore = true
+    return this.loadMore()
+  }
+
+  /**
+   * Removes posts from the feed upon deletion.
+   */
+  onPostDeleted(uri: string) {
+    for (const f of this.feeds) {
+      f.onPostDeleted(uri)
+    }
+  }
+
+  // state transitions
+  // =
+
+  _xLoading(isRefreshing = false) {
+    this.isLoading = true
+    this.isRefreshing = isRefreshing
+  }
+
+  _xIdle() {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = true
+  }
+
+  // helper functions
+  // =
+}
diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts
new file mode 100644
index 000000000..0c411d448
--- /dev/null
+++ b/src/state/models/feeds/post.ts
@@ -0,0 +1,265 @@
+import {makeAutoObservable} from 'mobx'
+import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api'
+import {RootStoreModel} from '../root-store'
+import {updateDataOptimistically} from 'lib/async/revertible'
+import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
+import {FeedViewPostsSlice} from 'lib/api/feed-manip'
+import {
+  getEmbedLabels,
+  getEmbedMuted,
+  getEmbedMutedByList,
+  getEmbedBlocking,
+  getEmbedBlockedBy,
+  getPostModeration,
+  filterAccountLabels,
+  filterProfileLabels,
+  mergePostModerations,
+} from 'lib/labeling/helpers'
+
+type FeedViewPost = AppBskyFeedDefs.FeedViewPost
+type ReasonRepost = AppBskyFeedDefs.ReasonRepost
+type PostView = AppBskyFeedDefs.PostView
+
+let _idCounter = 0
+
+export class PostsFeedItemModel {
+  // ui state
+  _reactKey: string = ''
+
+  // data
+  post: PostView
+  postRecord?: AppBskyFeedPost.Record
+  reply?: FeedViewPost['reply']
+  reason?: FeedViewPost['reason']
+  richText?: RichText
+
+  constructor(
+    public rootStore: RootStoreModel,
+    reactKey: string,
+    v: FeedViewPost,
+  ) {
+    this._reactKey = reactKey
+    this.post = v.post
+    if (AppBskyFeedPost.isRecord(this.post.record)) {
+      const valid = AppBskyFeedPost.validateRecord(this.post.record)
+      if (valid.success) {
+        this.postRecord = this.post.record
+        this.richText = new RichText(this.postRecord, {cleanNewlines: true})
+      } else {
+        this.postRecord = undefined
+        this.richText = undefined
+        rootStore.log.warn(
+          'Received an invalid app.bsky.feed.post record',
+          valid.error,
+        )
+      }
+    } else {
+      this.postRecord = undefined
+      this.richText = undefined
+      rootStore.log.warn(
+        'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type',
+        this.post.record,
+      )
+    }
+    this.reply = v.reply
+    this.reason = v.reason
+    makeAutoObservable(this, {rootStore: false})
+  }
+
+  get rootUri(): string {
+    if (this.reply?.root.uri) {
+      return this.reply.root.uri
+    }
+    return this.post.uri
+  }
+
+  get isThreadMuted() {
+    return this.rootStore.mutedThreads.uris.has(this.rootUri)
+  }
+
+  get labelInfo(): PostLabelInfo {
+    return {
+      postLabels: (this.post.labels || []).concat(
+        getEmbedLabels(this.post.embed),
+      ),
+      accountLabels: filterAccountLabels(this.post.author.labels),
+      profileLabels: filterProfileLabels(this.post.author.labels),
+      isMuted:
+        this.post.author.viewer?.muted ||
+        getEmbedMuted(this.post.embed) ||
+        false,
+      mutedByList:
+        this.post.author.viewer?.mutedByList ||
+        getEmbedMutedByList(this.post.embed),
+      isBlocking:
+        !!this.post.author.viewer?.blocking ||
+        getEmbedBlocking(this.post.embed) ||
+        false,
+      isBlockedBy:
+        !!this.post.author.viewer?.blockedBy ||
+        getEmbedBlockedBy(this.post.embed) ||
+        false,
+    }
+  }
+
+  get moderation(): PostModeration {
+    return getPostModeration(this.rootStore, this.labelInfo)
+  }
+
+  copy(v: FeedViewPost) {
+    this.post = v.post
+    this.reply = v.reply
+    this.reason = v.reason
+  }
+
+  copyMetrics(v: FeedViewPost) {
+    this.post.replyCount = v.post.replyCount
+    this.post.repostCount = v.post.repostCount
+    this.post.likeCount = v.post.likeCount
+    this.post.viewer = v.post.viewer
+  }
+
+  get reasonRepost(): ReasonRepost | undefined {
+    if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') {
+      return this.reason as ReasonRepost
+    }
+  }
+
+  async toggleLike() {
+    this.post.viewer = this.post.viewer || {}
+    if (this.post.viewer.like) {
+      const url = this.post.viewer.like
+      await updateDataOptimistically(
+        this.post,
+        () => {
+          this.post.likeCount = (this.post.likeCount || 0) - 1
+          this.post.viewer!.like = undefined
+        },
+        () => this.rootStore.agent.deleteLike(url),
+      )
+    } else {
+      await updateDataOptimistically(
+        this.post,
+        () => {
+          this.post.likeCount = (this.post.likeCount || 0) + 1
+          this.post.viewer!.like = 'pending'
+        },
+        () => this.rootStore.agent.like(this.post.uri, this.post.cid),
+        res => {
+          this.post.viewer!.like = res.uri
+        },
+      )
+    }
+  }
+
+  async toggleRepost() {
+    this.post.viewer = this.post.viewer || {}
+    if (this.post.viewer?.repost) {
+      const url = this.post.viewer.repost
+      await updateDataOptimistically(
+        this.post,
+        () => {
+          this.post.repostCount = (this.post.repostCount || 0) - 1
+          this.post.viewer!.repost = undefined
+        },
+        () => this.rootStore.agent.deleteRepost(url),
+      )
+    } else {
+      await updateDataOptimistically(
+        this.post,
+        () => {
+          this.post.repostCount = (this.post.repostCount || 0) + 1
+          this.post.viewer!.repost = 'pending'
+        },
+        () => this.rootStore.agent.repost(this.post.uri, this.post.cid),
+        res => {
+          this.post.viewer!.repost = res.uri
+        },
+      )
+    }
+  }
+
+  async toggleThreadMute() {
+    if (this.isThreadMuted) {
+      this.rootStore.mutedThreads.uris.delete(this.rootUri)
+    } else {
+      this.rootStore.mutedThreads.uris.add(this.rootUri)
+    }
+  }
+
+  async delete() {
+    await this.rootStore.agent.deletePost(this.post.uri)
+    this.rootStore.emitPostDeleted(this.post.uri)
+  }
+}
+
+export class PostsFeedSliceModel {
+  // ui state
+  _reactKey: string = ''
+
+  // data
+  items: PostsFeedItemModel[] = []
+
+  constructor(
+    public rootStore: RootStoreModel,
+    reactKey: string,
+    slice: FeedViewPostsSlice,
+  ) {
+    this._reactKey = reactKey
+    for (const item of slice.items) {
+      this.items.push(
+        new PostsFeedItemModel(rootStore, `slice-${_idCounter++}`, item),
+      )
+    }
+    makeAutoObservable(this, {rootStore: false})
+  }
+
+  get uri() {
+    if (this.isReply) {
+      return this.items[1].post.uri
+    }
+    return this.items[0].post.uri
+  }
+
+  get isThread() {
+    return (
+      this.items.length > 1 &&
+      this.items.every(
+        item => item.post.author.did === this.items[0].post.author.did,
+      )
+    )
+  }
+
+  get isReply() {
+    return this.items.length > 1 && !this.isThread
+  }
+
+  get rootItem() {
+    if (this.isReply) {
+      return this.items[1]
+    }
+    return this.items[0]
+  }
+
+  get moderation() {
+    return mergePostModerations(this.items.map(item => item.moderation))
+  }
+
+  containsUri(uri: string) {
+    return !!this.items.find(item => item.post.uri === uri)
+  }
+
+  isThreadParentAt(i: number) {
+    if (this.items.length === 1) {
+      return false
+    }
+    return i < this.items.length - 1
+  }
+
+  isThreadChildAt(i: number) {
+    if (this.items.length === 1) {
+      return false
+    }
+    return i > 0
+  }
+}
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index ac32044b4..02ef5f38b 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -1,11 +1,8 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {
   AppBskyFeedGetTimeline as GetTimeline,
-  AppBskyFeedDefs,
-  AppBskyFeedPost,
   AppBskyFeedGetAuthorFeed as GetAuthorFeed,
   AppBskyFeedGetFeed as GetCustomFeed,
-  RichText,
 } from '@atproto/api'
 import AwaitLock from 'await-lock'
 import {bundleAsync} from 'lib/async/bundle'
@@ -19,269 +16,11 @@ import {
   mergePosts,
 } from 'lib/api/build-suggested-posts'
 import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
-import {updateDataOptimistically} from 'lib/async/revertible'
-import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
-import {
-  getEmbedLabels,
-  getEmbedMuted,
-  getEmbedMutedByList,
-  getEmbedBlocking,
-  getEmbedBlockedBy,
-  getPostModeration,
-  mergePostModerations,
-  filterAccountLabels,
-  filterProfileLabels,
-} from 'lib/labeling/helpers'
-
-type FeedViewPost = AppBskyFeedDefs.FeedViewPost
-type ReasonRepost = AppBskyFeedDefs.ReasonRepost
-type PostView = AppBskyFeedDefs.PostView
+import {PostsFeedSliceModel} from './post'
 
 const PAGE_SIZE = 30
 let _idCounter = 0
 
-export class PostsFeedItemModel {
-  // ui state
-  _reactKey: string = ''
-
-  // data
-  post: PostView
-  postRecord?: AppBskyFeedPost.Record
-  reply?: FeedViewPost['reply']
-  reason?: FeedViewPost['reason']
-  richText?: RichText
-
-  constructor(
-    public rootStore: RootStoreModel,
-    reactKey: string,
-    v: FeedViewPost,
-  ) {
-    this._reactKey = reactKey
-    this.post = v.post
-    if (AppBskyFeedPost.isRecord(this.post.record)) {
-      const valid = AppBskyFeedPost.validateRecord(this.post.record)
-      if (valid.success) {
-        this.postRecord = this.post.record
-        this.richText = new RichText(this.postRecord, {cleanNewlines: true})
-      } else {
-        this.postRecord = undefined
-        this.richText = undefined
-        rootStore.log.warn(
-          'Received an invalid app.bsky.feed.post record',
-          valid.error,
-        )
-      }
-    } else {
-      this.postRecord = undefined
-      this.richText = undefined
-      rootStore.log.warn(
-        'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type',
-        this.post.record,
-      )
-    }
-    this.reply = v.reply
-    this.reason = v.reason
-    makeAutoObservable(this, {rootStore: false})
-  }
-
-  get rootUri(): string {
-    if (this.reply?.root.uri) {
-      return this.reply.root.uri
-    }
-    return this.post.uri
-  }
-
-  get isThreadMuted() {
-    return this.rootStore.mutedThreads.uris.has(this.rootUri)
-  }
-
-  get labelInfo(): PostLabelInfo {
-    return {
-      postLabels: (this.post.labels || []).concat(
-        getEmbedLabels(this.post.embed),
-      ),
-      accountLabels: filterAccountLabels(this.post.author.labels),
-      profileLabels: filterProfileLabels(this.post.author.labels),
-      isMuted:
-        this.post.author.viewer?.muted ||
-        getEmbedMuted(this.post.embed) ||
-        false,
-      mutedByList:
-        this.post.author.viewer?.mutedByList ||
-        getEmbedMutedByList(this.post.embed),
-      isBlocking:
-        !!this.post.author.viewer?.blocking ||
-        getEmbedBlocking(this.post.embed) ||
-        false,
-      isBlockedBy:
-        !!this.post.author.viewer?.blockedBy ||
-        getEmbedBlockedBy(this.post.embed) ||
-        false,
-    }
-  }
-
-  get moderation(): PostModeration {
-    return getPostModeration(this.rootStore, this.labelInfo)
-  }
-
-  copy(v: FeedViewPost) {
-    this.post = v.post
-    this.reply = v.reply
-    this.reason = v.reason
-  }
-
-  copyMetrics(v: FeedViewPost) {
-    this.post.replyCount = v.post.replyCount
-    this.post.repostCount = v.post.repostCount
-    this.post.likeCount = v.post.likeCount
-    this.post.viewer = v.post.viewer
-  }
-
-  get reasonRepost(): ReasonRepost | undefined {
-    if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') {
-      return this.reason as ReasonRepost
-    }
-  }
-
-  async toggleLike() {
-    this.post.viewer = this.post.viewer || {}
-    if (this.post.viewer.like) {
-      const url = this.post.viewer.like
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.likeCount = (this.post.likeCount || 0) - 1
-          this.post.viewer!.like = undefined
-        },
-        () => this.rootStore.agent.deleteLike(url),
-      )
-    } else {
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.likeCount = (this.post.likeCount || 0) + 1
-          this.post.viewer!.like = 'pending'
-        },
-        () => this.rootStore.agent.like(this.post.uri, this.post.cid),
-        res => {
-          this.post.viewer!.like = res.uri
-        },
-      )
-    }
-  }
-
-  async toggleRepost() {
-    this.post.viewer = this.post.viewer || {}
-    if (this.post.viewer?.repost) {
-      const url = this.post.viewer.repost
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.repostCount = (this.post.repostCount || 0) - 1
-          this.post.viewer!.repost = undefined
-        },
-        () => this.rootStore.agent.deleteRepost(url),
-      )
-    } else {
-      await updateDataOptimistically(
-        this.post,
-        () => {
-          this.post.repostCount = (this.post.repostCount || 0) + 1
-          this.post.viewer!.repost = 'pending'
-        },
-        () => this.rootStore.agent.repost(this.post.uri, this.post.cid),
-        res => {
-          this.post.viewer!.repost = res.uri
-        },
-      )
-    }
-  }
-
-  async toggleThreadMute() {
-    if (this.isThreadMuted) {
-      this.rootStore.mutedThreads.uris.delete(this.rootUri)
-    } else {
-      this.rootStore.mutedThreads.uris.add(this.rootUri)
-    }
-  }
-
-  async delete() {
-    await this.rootStore.agent.deletePost(this.post.uri)
-    this.rootStore.emitPostDeleted(this.post.uri)
-  }
-}
-
-export class PostsFeedSliceModel {
-  // ui state
-  _reactKey: string = ''
-
-  // data
-  items: PostsFeedItemModel[] = []
-
-  constructor(
-    public rootStore: RootStoreModel,
-    reactKey: string,
-    slice: FeedViewPostsSlice,
-  ) {
-    this._reactKey = reactKey
-    for (const item of slice.items) {
-      this.items.push(
-        new PostsFeedItemModel(rootStore, `item-${_idCounter++}`, item),
-      )
-    }
-    makeAutoObservable(this, {rootStore: false})
-  }
-
-  get uri() {
-    if (this.isReply) {
-      return this.items[1].post.uri
-    }
-    return this.items[0].post.uri
-  }
-
-  get isThread() {
-    return (
-      this.items.length > 1 &&
-      this.items.every(
-        item => item.post.author.did === this.items[0].post.author.did,
-      )
-    )
-  }
-
-  get isReply() {
-    return this.items.length > 1 && !this.isThread
-  }
-
-  get rootItem() {
-    if (this.isReply) {
-      return this.items[1]
-    }
-    return this.items[0]
-  }
-
-  get moderation() {
-    return mergePostModerations(this.items.map(item => item.moderation))
-  }
-
-  containsUri(uri: string) {
-    return !!this.items.find(item => item.post.uri === uri)
-  }
-
-  isThreadParentAt(i: number) {
-    if (this.items.length === 1) {
-      return false
-    }
-    return i < this.items.length - 1
-  }
-
-  isThreadChildAt(i: number) {
-    if (this.items.length === 1) {
-      return false
-    }
-    return i > 0
-  }
-}
-
 export class PostsFeedModel {
   // state
   isLoading = false
@@ -297,6 +36,7 @@ export class PostsFeedModel {
   loadMoreCursor: string | undefined
   pollCursor: string | undefined
   tuner = new FeedTuner()
+  pageSize = PAGE_SIZE
 
   // used to linearize async modifications to state
   lock = new AwaitLock()
@@ -418,7 +158,7 @@ export class PostsFeedModel {
       this.tuner.reset()
       this._xLoading(isRefreshing)
       try {
-        const res = await this._getFeed({limit: PAGE_SIZE})
+        const res = await this._getFeed({limit: this.pageSize})
         await this._replaceAll(res)
         this._xIdle()
       } catch (e: any) {
@@ -457,7 +197,7 @@ export class PostsFeedModel {
       try {
         const res = await this._getFeed({
           cursor: this.loadMoreCursor,
-          limit: PAGE_SIZE,
+          limit: this.pageSize,
         })
         await this._appendAll(res)
         this._xIdle()
@@ -526,7 +266,7 @@ export class PostsFeedModel {
     if (this.hasNewLatest || this.feedType === 'suggested') {
       return
     }
-    const res = await this._getFeed({limit: PAGE_SIZE})
+    const res = await this._getFeed({limit: this.pageSize})
     const tuner = new FeedTuner()
     const slices = tuner.tune(res.data.feed, this.feedTuners)
     this.setHasNewLatest(slices[0]?.uri !== this.slices[0]?.uri)
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index 574265eb7..0d71b2b98 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -8,7 +8,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
-import {SatelliteDishIcon} from 'lib/icons'
+import {CogIcon} from 'lib/icons'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {s} from 'lib/styles'
 
@@ -69,11 +69,7 @@ export const FeedsTabBar = observer(
               accessibilityRole="button"
               accessibilityLabel="Edit Saved Feeds"
               accessibilityHint="Opens screen to edit Saved Feeds">
-              <SatelliteDishIcon
-                size={20}
-                strokeWidth={2}
-                style={pal.textLight}
-              />
+              <CogIcon size={21} strokeWidth={2} style={pal.textLight} />
             </Link>
           </View>
         </View>
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index bbba74c87..b0a02ea22 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -131,7 +131,7 @@ const styles = isDesktopWeb
         backgroundColor: 'transparent',
       },
       contentContainer: {
-        columnGap: 16,
+        columnGap: 20,
         marginLeft: 18,
         paddingRight: 28,
         backgroundColor: 'transparent',
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index b90213472..8206ca509 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -33,7 +33,6 @@ export const Feed = observer(function Feed({
   onPressTryAgain,
   onScroll,
   scrollEventThrottle,
-  onMomentumScrollEnd,
   renderEmptyState,
   testID,
   headerOffset = 0,
@@ -186,7 +185,6 @@ export const Feed = observer(function Feed({
           style={{paddingTop: headerOffset}}
           onScroll={onScroll}
           scrollEventThrottle={scrollEventThrottle}
-          onMomentumScrollEnd={onMomentumScrollEnd}
           indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
           onEndReached={onEndReached}
           onEndReachedThreshold={0.6}
diff --git a/src/view/com/posts/MultiFeed.tsx b/src/view/com/posts/MultiFeed.tsx
new file mode 100644
index 000000000..4911c9e2c
--- /dev/null
+++ b/src/view/com/posts/MultiFeed.tsx
@@ -0,0 +1,230 @@
+import React, {MutableRefObject} from 'react'
+import {observer} from 'mobx-react-lite'
+import {
+  ActivityIndicator,
+  RefreshControl,
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {FlatList} from '../util/Views'
+import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {PostsMultiFeedModel, MultiFeedItem} from 'state/models/feeds/multi-feed'
+import {FeedSlice} from './FeedSlice'
+import {Text} from '../util/text/Text'
+import {Link} from '../util/Link'
+import {UserAvatar} from '../util/UserAvatar'
+import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
+import {s} from 'lib/styles'
+import {useAnalytics} from 'lib/analytics'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+
+export const MultiFeed = observer(function Feed({
+  multifeed,
+  style,
+  showPostFollowBtn,
+  scrollElRef,
+  onScroll,
+  scrollEventThrottle,
+  testID,
+  headerOffset = 0,
+  extraData,
+}: {
+  multifeed: PostsMultiFeedModel
+  style?: StyleProp<ViewStyle>
+  showPostFollowBtn?: boolean
+  scrollElRef?: MutableRefObject<FlatList<any> | null>
+  onPressTryAgain?: () => void
+  onScroll?: OnScrollCb
+  scrollEventThrottle?: number
+  renderEmptyState?: () => JSX.Element
+  testID?: string
+  headerOffset?: number
+  extraData?: any
+}) {
+  const pal = usePalette('default')
+  const palInverted = usePalette('inverted')
+  const theme = useTheme()
+  const {track} = useAnalytics()
+  const [isRefreshing, setIsRefreshing] = React.useState(false)
+
+  // events
+  // =
+
+  const onRefresh = React.useCallback(async () => {
+    track('MultiFeed:onRefresh')
+    setIsRefreshing(true)
+    try {
+      await multifeed.refresh()
+    } catch (err) {
+      multifeed.rootStore.log.error('Failed to refresh posts feed', err)
+    }
+    setIsRefreshing(false)
+  }, [multifeed, track, setIsRefreshing])
+
+  const onEndReached = React.useCallback(async () => {
+    track('MultiFeed:onEndReached')
+    try {
+      await multifeed.loadMore()
+    } catch (err) {
+      multifeed.rootStore.log.error('Failed to load more posts', err)
+    }
+  }, [multifeed, track])
+
+  // rendering
+  // =
+
+  const renderItem = React.useCallback(
+    ({item}: {item: MultiFeedItem}) => {
+      if (item.type === 'header') {
+        return <View style={[styles.header, pal.border]} />
+      } else if (item.type === 'feed-header') {
+        return (
+          <View style={styles.feedHeader}>
+            <UserAvatar type="algo" avatar={item.avatar} size={28} />
+            <Text type="title-lg" style={[pal.text, styles.feedHeaderTitle]}>
+              {item.title}
+            </Text>
+          </View>
+        )
+      } else if (item.type === 'feed-slice') {
+        return (
+          <FeedSlice slice={item.slice} showFollowBtn={showPostFollowBtn} />
+        )
+      } else if (item.type === 'feed-loading') {
+        return <PostFeedLoadingPlaceholder />
+      } else if (item.type === 'feed-error') {
+        return <ErrorMessage message={item.error} />
+      } else if (item.type === 'feed-footer') {
+        return (
+          <Link
+            href={item.uri}
+            style={[styles.feedFooter, pal.border, pal.view]}>
+            <Text type="lg" style={pal.link}>
+              See more from {item.title}
+            </Text>
+            <FontAwesomeIcon
+              icon="angle-right"
+              size={18}
+              color={pal.colors.link}
+            />
+          </Link>
+        )
+      } else if (item.type === 'footer') {
+        return (
+          <Link
+            style={[styles.footerLink, palInverted.view]}
+            href="/search/feeds">
+            <FontAwesomeIcon
+              icon="search"
+              size={18}
+              color={palInverted.colors.text}
+            />
+            <Text type="lg-medium" style={palInverted.text}>
+              Discover new feeds
+            </Text>
+          </Link>
+        )
+      }
+      return null
+    },
+    [showPostFollowBtn, pal, palInverted],
+  )
+
+  const FeedFooter = React.useCallback(
+    () =>
+      multifeed.isLoading && !isRefreshing ? (
+        <View style={styles.loadMore}>
+          <ActivityIndicator color={pal.colors.text} />
+        </View>
+      ) : (
+        <View />
+      ),
+    [multifeed.isLoading, isRefreshing, pal],
+  )
+
+  return (
+    <View testID={testID} style={style}>
+      {multifeed.items.length > 0 && (
+        <FlatList
+          testID={testID ? `${testID}-flatlist` : undefined}
+          ref={scrollElRef}
+          data={multifeed.items}
+          keyExtractor={item => item._reactKey}
+          renderItem={renderItem}
+          ListFooterComponent={FeedFooter}
+          refreshControl={
+            <RefreshControl
+              refreshing={isRefreshing}
+              onRefresh={onRefresh}
+              tintColor={pal.colors.text}
+              titleColor={pal.colors.text}
+              progressViewOffset={headerOffset}
+            />
+          }
+          contentContainerStyle={s.contentContainer}
+          style={[{paddingTop: headerOffset}, pal.viewLight, styles.container]}
+          onScroll={onScroll}
+          scrollEventThrottle={scrollEventThrottle}
+          indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
+          onEndReached={onEndReached}
+          onEndReachedThreshold={0.6}
+          removeClippedSubviews={true}
+          contentOffset={{x: 0, y: headerOffset * -1}}
+          extraData={extraData}
+          // @ts-ignore our .web version only -prf
+          desktopFixedHeight
+        />
+      )}
+    </View>
+  )
+})
+
+const styles = StyleSheet.create({
+  container: {
+    height: '100%',
+  },
+  header: {
+    borderTopWidth: 1,
+    marginBottom: 4,
+  },
+  feedHeader: {
+    flexDirection: 'row',
+    gap: 8,
+    alignItems: 'center',
+    paddingHorizontal: 16,
+    paddingBottom: 8,
+    marginTop: 12,
+  },
+  feedHeaderTitle: {
+    fontWeight: 'bold',
+  },
+  feedFooter: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    paddingHorizontal: 16,
+    paddingVertical: 16,
+    marginBottom: 12,
+    borderTopWidth: 1,
+    borderBottomWidth: 1,
+  },
+  footerLink: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    borderRadius: 8,
+    paddingHorizontal: 14,
+    paddingVertical: 12,
+    marginHorizontal: 8,
+    marginBottom: 8,
+    gap: 8,
+  },
+  loadMore: {
+    paddingTop: 10,
+  },
+})
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
new file mode 100644
index 000000000..5d5ed6c16
--- /dev/null
+++ b/src/view/screens/Feeds.tsx
@@ -0,0 +1,125 @@
+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}
+        />
+        <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/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index dac554710..103b18c70 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -118,7 +118,11 @@ export const SavedFeeds = withAuthRequired(
           pal.border,
           isDesktopWeb && styles.desktopContainer,
         ]}>
-        <ViewHeader title="My Feeds" showOnDesktop showBorder={!isDesktopWeb} />
+        <ViewHeader
+          title="Edit My Feeds"
+          showOnDesktop
+          showBorder={!isDesktopWeb}
+        />
         <DraggableFlatList
           containerStyle={[!isDesktopWeb && s.flex1]}
           data={savedFeeds.all}
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 5617cd5b8..57f4ee696 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -30,6 +30,7 @@ import {
   MoonIcon,
   UserIconSolid,
   SatelliteDishIcon,
+  SatelliteDishIconSolid,
   HandIcon,
 } from 'lib/icons'
 import {UserAvatar} from 'view/com/util/UserAvatar'
@@ -50,7 +51,7 @@ export const DrawerContent = observer(() => {
   const store = useStores()
   const navigation = useNavigation<NavigationProp>()
   const {track} = useAnalytics()
-  const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} =
+  const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
     useNavigationTabState()
 
   const {notifications} = store.me
@@ -97,11 +98,10 @@ export const DrawerContent = observer(() => {
     onPressTab('MyProfile')
   }, [onPressTab])
 
-  const onPressMyFeeds = React.useCallback(() => {
-    track('Menu:ItemClicked', {url: 'MyFeeds'})
-    navigation.navigate('SavedFeeds')
-    store.shell.closeDrawer()
-  }, [navigation, track, store.shell])
+  const onPressMyFeeds = React.useCallback(
+    () => onPressTab('Feeds'),
+    [onPressTab],
+  )
 
   const onPressModeration = React.useCallback(() => {
     track('Menu:ItemClicked', {url: 'Moderation'})
@@ -240,11 +240,19 @@ export const DrawerContent = observer(() => {
           />
           <MenuItem
             icon={
-              <SatelliteDishIcon
-                strokeWidth={1.5}
-                style={pal.text as FontAwesomeIconStyle}
-                size={24}
-              />
+              isAtFeeds ? (
+                <SatelliteDishIconSolid
+                  strokeWidth={1.5}
+                  style={pal.text as FontAwesomeIconStyle}
+                  size={24}
+                />
+              ) : (
+                <SatelliteDishIcon
+                  strokeWidth={1.5}
+                  style={pal.text as FontAwesomeIconStyle}
+                  size={24}
+                />
+              )
             }
             label="My Feeds"
             accessibilityLabel="My Feeds"
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index 394aef7af..e8cba9047 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -18,6 +18,8 @@ import {
   HomeIconSolid,
   MagnifyingGlassIcon2,
   MagnifyingGlassIcon2Solid,
+  SatelliteDishIcon,
+  SatelliteDishIconSolid,
   BellIcon,
   BellIconSolid,
 } from 'lib/icons'
@@ -33,7 +35,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
   const pal = usePalette('default')
   const safeAreaInsets = useSafeAreaInsets()
   const {track} = useAnalytics()
-  const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} =
+  const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
     useNavigationTabState()
 
   const {footerMinimalShellTransform} = useMinimalShellMode()
@@ -59,6 +61,10 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
     () => onPressTab('Search'),
     [onPressTab],
   )
+  const onPressFeeds = React.useCallback(
+    () => onPressTab('Feeds'),
+    [onPressTab],
+  )
   const onPressNotifications = React.useCallback(
     () => onPressTab('Notifications'),
     [onPressTab],
@@ -120,6 +126,28 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
         accessibilityLabel="Search"
         accessibilityHint=""
       />
+      <Btn
+        testID="bottomBarFeedsBtn"
+        icon={
+          isAtFeeds ? (
+            <SatelliteDishIconSolid
+              size={25}
+              style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
+              strokeWidth={1.8}
+            />
+          ) : (
+            <SatelliteDishIcon
+              size={25}
+              style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
+              strokeWidth={1.8}
+            />
+          )
+        }
+        onPress={onPressFeeds}
+        accessibilityRole="tab"
+        accessibilityLabel="Feeds"
+        accessibilityHint=""
+      />
       <Btn
         testID="bottomBarNotificationsBtn"
         icon={
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index e62b47ca9..3b14d7e99 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -207,7 +207,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
         label="Notifications"
       />
       <NavItem
-        href="/settings/saved-feeds"
+        href="/feeds"
         icon={
           <SatelliteDishIcon
             strokeWidth={1.75}
-- 
cgit 1.4.1


From fa9351534b700fc275032830dd91e79a77609f3c Mon Sep 17 00:00:00 2001
From: Matin Zadeh Dolatabad <zadehdolatabad@gmail.com>
Date: Wed, 31 May 2023 03:16:30 +0200
Subject: perf: enable lazy loading for tab navigation (#711)

---
 src/Navigation.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

(limited to 'src/Navigation.tsx')

diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 7da77b877..87330e3bf 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -225,7 +225,7 @@ function TabsNavigator() {
     <Tab.Navigator
       initialRouteName="HomeTab"
       backBehavior="initialRoute"
-      screenOptions={{headerShown: false}}
+      screenOptions={{headerShown: false, lazy: true}}
       tabBar={tabBar}>
       <Tab.Screen name="HomeTab" component={HomeTabNavigator} />
       <Tab.Screen name="SearchTab" component={SearchTabNavigator} />
-- 
cgit 1.4.1

