From 8bb3f40a59232f71ae2b09509e3ae2559a04e4a7 Mon Sep 17 00:00:00 2001
From: Ansh Nanda <anshnanda10@gmail.com>
Date: Thu, 25 May 2023 13:14:40 -0700
Subject: add saved feeds to left nav on desktop

---
 src/view/shell/desktop/LeftNav.tsx | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)

(limited to 'src/view/shell/desktop/LeftNav.tsx')

diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index ba48dd2ae..01cace154 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -204,6 +204,24 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
         }
         label="Notifications"
       />
+      <NavItem
+        href="/settings/saved-feeds"
+        icon={
+          <FontAwesomeIcon
+            icon="satellite-dish"
+            style={pal.text as FontAwesomeIconStyle}
+            size={20}
+          />
+        }
+        iconFilled={
+          <FontAwesomeIcon
+            icon="satellite-dish"
+            style={pal.text as FontAwesomeIconStyle}
+            size={20}
+          />
+        }
+        label="My Feeds"
+      />
       <NavItem
         href="/moderation"
         icon={
-- 
cgit 1.4.1


From 20ef1b8b7bd52eed8983a0232d42f3806a07fb00 Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
Date: Thu, 25 May 2023 16:51:12 -0500
Subject: Add custom satellite-dish icons

---
 src/lib/icons.tsx                  | 76 +++++++++++++++++++++++++++++++++++++-
 src/view/shell/desktop/LeftNav.tsx | 14 ++++---
 2 files changed, 83 insertions(+), 7 deletions(-)

(limited to 'src/view/shell/desktop/LeftNav.tsx')

diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx
index 0c7b7512a..47a38fb63 100644
--- a/src/lib/icons.tsx
+++ b/src/lib/icons.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {StyleProp, TextStyle, ViewStyle} from 'react-native'
-import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg'
+import Svg, {Path, Rect, Line, Ellipse, Circle} from 'react-native-svg'
 
 export function GridIcon({
   style,
@@ -883,3 +883,77 @@ export function HandIcon({
     </Svg>
   )
 }
+
+export function SatelliteDishIconSolid({
+  style,
+  size,
+  strokeWidth = 1.5,
+}: {
+  style?: StyleProp<ViewStyle>
+  size?: string | number
+  strokeWidth?: number
+}) {
+  return (
+    <Svg
+      width={size || 24}
+      height={size || 24}
+      viewBox="0 0 22 22"
+      style={style}
+      fill="none"
+      stroke="none">
+      <Path
+        d="M16 19.6622C14.5291 20.513 12.8214 21 11 21C5.47715 21 1 16.5229 1 11C1 9.17858 1.48697 7.47088 2.33782 6.00002C3.18867 4.52915 6 7.66219 6 7.66219L14.5 16.1622C14.5 16.1622 17.4709 18.8113 16 19.6622Z"
+        fill="currentColor"
+      />
+      <Path
+        d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14"
+        stroke="currentColor"
+        strokeWidth={strokeWidth}
+        strokeLinecap="round"
+      />
+      <Path
+        d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13"
+        stroke="currentColor"
+        strokeWidth={strokeWidth}
+        strokeLinecap="round"
+      />
+      <Circle cx="10" cy="12" r="2" fill="currentColor" />
+    </Svg>
+  )
+}
+
+export function SatelliteDishIcon({
+  style,
+  size,
+  strokeWidth = 1.5,
+}: {
+  style?: StyleProp<TextStyle>
+  size?: string | number
+  strokeWidth?: number
+}) {
+  return (
+    <Svg
+      fill="none"
+      viewBox="0 0 22 22"
+      strokeWidth={strokeWidth}
+      stroke="currentColor"
+      width={size}
+      height={size}
+      style={style}>
+      <Path d="M5.25593 8.3303L5.25609 8.33047L5.25616 8.33056L5.25621 8.33061L5.27377 8.35018L5.29289 8.3693L13.7929 16.8693L13.8131 16.8895L13.8338 16.908L13.834 16.9081L13.8342 16.9083L13.8342 16.9083L13.8345 16.9086L13.8381 16.9118L13.8574 16.9294C13.8752 16.9458 13.9026 16.9711 13.9377 17.0043C14.0081 17.0708 14.1088 17.1683 14.2258 17.2881C14.4635 17.5315 14.7526 17.8509 14.9928 18.1812C15.2067 18.4755 15.3299 18.7087 15.3817 18.8634C14.0859 19.5872 12.5926 20 11 20C6.02944 20 2 15.9706 2 11C2 9.4151 2.40883 7.9285 3.12619 6.63699C3.304 6.69748 3.56745 6.84213 3.89275 7.08309C4.24679 7.34534 4.58866 7.65673 4.84827 7.9106C4.97633 8.03583 5.08062 8.14337 5.152 8.21863C5.18763 8.25619 5.21487 8.28551 5.23257 8.30473L5.25178 8.32572L5.25571 8.33006L5.25593 8.3303ZM3.00217 6.60712C3.00217 6.6071 3.00267 6.6071 3.00372 6.60715C3.00271 6.60716 3.00218 6.60714 3.00217 6.60712Z" />
+      <Path
+        d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14"
+        stroke-linecap="round"
+      />
+      <Path
+        d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13"
+        stroke-linecap="round"
+      />
+      <Path
+        d="M12 12C12 12.7403 11.5978 13.3866 11 13.7324L8.26756 11C8.61337 10.4022 9.25972 10 10 10C11.1046 10 12 10.8954 12 12Z"
+        fill="currentColor"
+        stroke="none"
+      />
+    </Svg>
+  )
+}
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index 01cace154..e62b47ca9 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -30,6 +30,8 @@ import {
   CogIconSolid,
   ComposeIcon2,
   HandIcon,
+  SatelliteDishIcon,
+  SatelliteDishIconSolid,
 } from 'lib/icons'
 import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers'
 import {NavigationProp} from 'lib/routes/types'
@@ -207,17 +209,17 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
       <NavItem
         href="/settings/saved-feeds"
         icon={
-          <FontAwesomeIcon
-            icon="satellite-dish"
+          <SatelliteDishIcon
+            strokeWidth={1.75}
             style={pal.text as FontAwesomeIconStyle}
-            size={20}
+            size={24}
           />
         }
         iconFilled={
-          <FontAwesomeIcon
-            icon="satellite-dish"
+          <SatelliteDishIconSolid
+            strokeWidth={1.75}
             style={pal.text as FontAwesomeIconStyle}
-            size={20}
+            size={24}
           />
         }
         label="My Feeds"
-- 
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/view/shell/desktop/LeftNav.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 651f3abc1ff0913347ed7bfb824f8e280f87a850 Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
Date: Thu, 25 May 2023 20:19:49 -0500
Subject: Tune up custom feed uis on web

---
 src/view/com/pager/TabBar.tsx      |  5 +++--
 src/view/com/posts/MultiFeed.tsx   | 29 ++++++++++++++++++++++++++++-
 src/view/shell/desktop/LeftNav.tsx | 18 +++++++++---------
 3 files changed, 40 insertions(+), 12 deletions(-)

(limited to 'src/view/shell/desktop/LeftNav.tsx')

diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index 70500db2c..422d986a7 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -92,7 +92,7 @@ export function TabBar({
               hoverStyle={pal.viewLight}
               onPress={() => onPressItem(i)}>
               <Text
-                type="lg-bold"
+                type={isDesktopWeb ? 'xl-bold' : 'lg-bold'}
                 testID={testID ? `${testID}-${item}` : undefined}
                 style={selected ? pal.text : pal.textLight}>
                 {item}
@@ -112,7 +112,7 @@ const styles = isDesktopWeb
         width: 598,
       },
       contentContainer: {
-        columnGap: 14,
+        columnGap: 8,
         marginLeft: 14,
         paddingRight: 14,
         backgroundColor: 'transparent',
@@ -120,6 +120,7 @@ const styles = isDesktopWeb
       item: {
         paddingTop: 14,
         paddingBottom: 12,
+        paddingHorizontal: 10,
         borderBottomWidth: 3,
         borderBottomColor: 'transparent',
       },
diff --git a/src/view/com/posts/MultiFeed.tsx b/src/view/com/posts/MultiFeed.tsx
index 4911c9e2c..75c57c446 100644
--- a/src/view/com/posts/MultiFeed.tsx
+++ b/src/view/com/posts/MultiFeed.tsx
@@ -22,6 +22,8 @@ import {s} from 'lib/styles'
 import {useAnalytics} from 'lib/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
+import {isDesktopWeb} from 'platform/detection'
+import {CogIcon} from 'lib/icons'
 
 export const MultiFeed = observer(function Feed({
   multifeed,
@@ -81,6 +83,18 @@ export const MultiFeed = observer(function Feed({
   const renderItem = React.useCallback(
     ({item}: {item: MultiFeedItem}) => {
       if (item.type === 'header') {
+        if (isDesktopWeb) {
+          return (
+            <View style={[pal.view, pal.border, styles.headerDesktop]}>
+              <Text type="2xl-bold" style={pal.text}>
+                My Feeds
+              </Text>
+              <Link href="/settings/saved-feeds">
+                <CogIcon strokeWidth={1.5} style={pal.icon} size={28} />
+              </Link>
+            </View>
+          )
+        }
         return <View style={[styles.header, pal.border]} />
       } else if (item.type === 'feed-header') {
         return (
@@ -167,7 +181,11 @@ export const MultiFeed = observer(function Feed({
             />
           }
           contentContainerStyle={s.contentContainer}
-          style={[{paddingTop: headerOffset}, pal.viewLight, styles.container]}
+          style={[
+            {paddingTop: headerOffset},
+            isDesktopWeb ? pal.view : pal.viewLight,
+            styles.container,
+          ]}
           onScroll={onScroll}
           scrollEventThrottle={scrollEventThrottle}
           indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
@@ -192,6 +210,15 @@ const styles = StyleSheet.create({
     borderTopWidth: 1,
     marginBottom: 4,
   },
+  headerDesktop: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'space-between',
+    borderBottomWidth: 1,
+    marginBottom: 4,
+    paddingHorizontal: 16,
+    paddingVertical: 8,
+  },
   feedHeader: {
     flexDirection: 'row',
     gap: 8,
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index 3b14d7e99..ce232a730 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -197,15 +197,6 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
         }
         label="Search"
       />
-      <NavItem
-        href="/notifications"
-        count={store.me.notifications.unreadCountLabel}
-        icon={<BellIcon strokeWidth={2} size={24} style={pal.text} />}
-        iconFilled={
-          <BellIconSolid strokeWidth={1.5} size={24} style={pal.text} />
-        }
-        label="Notifications"
-      />
       <NavItem
         href="/feeds"
         icon={
@@ -224,6 +215,15 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
         }
         label="My Feeds"
       />
+      <NavItem
+        href="/notifications"
+        count={store.me.notifications.unreadCountLabel}
+        icon={<BellIcon strokeWidth={2} size={24} style={pal.text} />}
+        iconFilled={
+          <BellIconSolid strokeWidth={1.5} size={24} style={pal.text} />
+        }
+        label="Notifications"
+      />
       <NavItem
         href="/moderation"
         icon={
-- 
cgit 1.4.1


From 442d453600a5dce98a2c197c94c9fb3d3a7fba78 Mon Sep 17 00:00:00 2001
From: MohammadReza Mahmoudi <rezamahmudy1@gmail.com>
Date: Wed, 31 May 2023 03:12:22 +0330
Subject: Fix profile load issue + enhancement of profile isCurrent style
 (#764)

* fix profile isActive issue

* fix myProfile load issue when visiting other profiles first

* fix lint issues

* change currentRouteName to currentRouteInfo
---
 src/view/screens/Profile.tsx       |  4 ++++
 src/view/shell/desktop/LeftNav.tsx | 13 ++++++++-----
 2 files changed, 12 insertions(+), 5 deletions(-)

(limited to 'src/view/shell/desktop/LeftNav.tsx')

diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 77e3743e5..a34ceb32c 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -47,6 +47,10 @@ export const ProfileScreen = withAuthRequired(
     )
     useSetTitle(combinedDisplayName(uiState.profile))
 
+    useEffect(() => {
+      setHasSetup(false)
+    }, [route.params.name])
+
     useFocusEffect(
       React.useCallback(() => {
         let aborted = false
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index ce232a730..914dfb53a 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -91,14 +91,17 @@ const NavItem = observer(
     const pal = usePalette('default')
     const store = useStores()
     const [pathName] = React.useMemo(() => router.matchPath(href), [href])
-    const currentRouteName = useNavigationState(state => {
+    const currentRouteInfo = useNavigationState(state => {
       if (!state) {
-        return 'Home'
+        return {name: 'Home'}
       }
-      return getCurrentRoute(state).name
+      return getCurrentRoute(state)
     })
-
-    const isCurrent = isTab(currentRouteName, pathName)
+    let isCurrent =
+      currentRouteInfo.name === 'Profile'
+        ? isTab(currentRouteInfo.name, pathName) &&
+          currentRouteInfo.params.name === store.me.handle
+        : isTab(currentRouteInfo.name, pathName)
     const {onPress} = useLinkProps({to: href})
     const onPressWrapped = React.useCallback(
       (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
-- 
cgit 1.4.1

