about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package.json2
-rw-r--r--src/Navigation.tsx6
-rw-r--r--src/lib/api/feed-manip.ts47
-rw-r--r--src/lib/api/feed/author.ts45
-rw-r--r--src/lib/api/feed/custom.ts52
-rw-r--r--src/lib/api/feed/following.ts37
-rw-r--r--src/lib/api/feed/likes.ts45
-rw-r--r--src/lib/api/feed/merge.ts236
-rw-r--r--src/lib/api/feed/types.ts17
-rw-r--r--src/lib/icons.tsx64
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/lib/strings/url-helpers.ts9
-rw-r--r--src/routes.ts1
-rw-r--r--src/state/models/feeds/multi-feed.ts227
-rw-r--r--src/state/models/feeds/posts-slice.ts3
-rw-r--r--src/state/models/feeds/posts.ts172
-rw-r--r--src/state/models/root-store.ts2
-rw-r--r--src/state/models/ui/my-feeds.ts157
-rw-r--r--src/state/models/ui/preferences.ts31
-rw-r--r--src/state/models/ui/profile.ts7
-rw-r--r--src/view/com/notifications/Feed.tsx5
-rw-r--r--src/view/com/pager/FeedsTabBar.web.tsx8
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx13
-rw-r--r--src/view/com/post-thread/PostThread.tsx2
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx9
-rw-r--r--src/view/com/post/Post.tsx22
-rw-r--r--src/view/com/posts/FeedItem.tsx57
-rw-r--r--src/view/com/posts/FeedSlice.tsx2
-rw-r--r--src/view/com/posts/FollowingEmptyState.tsx2
-rw-r--r--src/view/com/posts/MultiFeed.tsx256
-rw-r--r--src/view/com/util/Link.tsx15
-rw-r--r--src/view/com/util/LoadingPlaceholder.tsx54
-rw-r--r--src/view/com/util/SimpleViewHeader.tsx105
-rw-r--r--src/view/com/util/UserAvatar.tsx2
-rw-r--r--src/view/com/util/forms/SearchInput.tsx104
-rw-r--r--src/view/com/util/load-latest/LoadLatestBtn.tsx87
-rw-r--r--src/view/com/util/load-latest/LoadLatestBtn.web.tsx109
-rw-r--r--src/view/com/util/load-latest/LoadLatestBtnMobile.tsx69
-rw-r--r--src/view/index.ts24
-rw-r--r--src/view/screens/CustomFeed.tsx391
-rw-r--r--src/view/screens/DiscoverFeeds.tsx157
-rw-r--r--src/view/screens/Feeds.tsx320
-rw-r--r--src/view/screens/Home.tsx87
-rw-r--r--src/view/screens/Notifications.tsx57
-rw-r--r--src/view/screens/PreferencesHomeFeed.tsx66
-rw-r--r--src/view/screens/Profile.tsx4
-rw-r--r--src/view/screens/SavedFeeds.tsx2
-rw-r--r--src/view/screens/Settings.tsx11
-rw-r--r--src/view/shell/Drawer.tsx15
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx19
-rw-r--r--src/view/shell/bottom-bar/BottomBarStyles.tsx3
-rw-r--r--src/view/shell/bottom-bar/BottomBarWeb.tsx12
-rw-r--r--src/view/shell/desktop/Feeds.tsx92
-rw-r--r--src/view/shell/desktop/LeftNav.tsx13
-rw-r--r--src/view/shell/desktop/RightNav.tsx5
-rw-r--r--src/view/shell/desktop/Search.tsx1
-rw-r--r--yarn.lock20
57 files changed, 1884 insertions, 1497 deletions
diff --git a/package.json b/package.json
index 90d12e773..faa515749 100644
--- a/package.json
+++ b/package.json
@@ -102,6 +102,7 @@
     "lodash.isequal": "^4.5.0",
     "lodash.omit": "^4.5.0",
     "lodash.once": "^4.1.1",
+    "lodash.random": "^3.2.0",
     "lodash.samplesize": "^4.2.0",
     "lodash.set": "^4.3.2",
     "lodash.shuffle": "^4.2.0",
@@ -168,6 +169,7 @@
     "@types/lodash.isequal": "^4.5.6",
     "@types/lodash.omit": "^4.5.7",
     "@types/lodash.once": "^4.1.7",
+    "@types/lodash.random": "^3.2.7",
     "@types/lodash.samplesize": "^4.2.7",
     "@types/lodash.set": "^4.3.7",
     "@types/lodash.shuffle": "^4.2.7",
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index c16ff3a8c..9bf6ba981 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -40,7 +40,6 @@ import {FeedsScreen} from './view/screens/Feeds'
 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'
@@ -114,11 +113,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         options={{title: title('Blocked Accounts')}}
       />
       <Stack.Screen
-        name="DiscoverFeeds"
-        component={DiscoverFeedsScreen}
-        options={{title: title('Discover Feeds')}}
-      />
-      <Stack.Screen
         name="Settings"
         component={SettingsScreen}
         options={{title: title('Settings')}}
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
index 149859ea9..ef57fc4f2 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -4,6 +4,7 @@ import {
   AppBskyEmbedRecordWithMedia,
   AppBskyEmbedRecord,
 } from '@atproto/api'
+import {FeedSourceInfo} from './feed/types'
 import {isPostInLanguage} from '../../locale/helpers'
 type FeedViewPost = AppBskyFeedDefs.FeedViewPost
 
@@ -64,6 +65,11 @@ export class FeedViewPostsSlice {
     )
   }
 
+  get source(): FeedSourceInfo | undefined {
+    return this.items.find(item => '__source' in item && !!item.__source)
+      ?.__source as FeedSourceInfo
+  }
+
   containsUri(uri: string) {
     return !!this.items.find(item => item.post.uri === uri)
   }
@@ -91,6 +97,23 @@ export class FeedViewPostsSlice {
       }
     }
   }
+
+  isFollowingAllAuthors(userDid: string) {
+    const item = this.rootItem
+    if (item.post.author.did === userDid) {
+      return true
+    }
+    if (AppBskyFeedDefs.isPostView(item.reply?.parent)) {
+      const parent = item.reply?.parent
+      if (parent?.author.did === userDid) {
+        return true
+      }
+      return (
+        parent?.author.viewer?.following && item.post.author.viewer?.following
+      )
+    }
+    return false
+  }
 }
 
 export class FeedTuner {
@@ -222,20 +245,34 @@ export class FeedTuner {
     return slices
   }
 
-  static likedRepliesOnly({repliesThreshold}: {repliesThreshold: number}) {
+  static thresholdRepliesOnly({
+    userDid,
+    minLikes,
+    followedOnly,
+  }: {
+    userDid: string
+    minLikes: number
+    followedOnly: boolean
+  }) {
     return (
       tuner: FeedTuner,
       slices: FeedViewPostsSlice[],
     ): FeedViewPostsSlice[] => {
-      // remove any replies without at least repliesThreshold likes
+      // remove any replies without at least minLikes likes
       for (let i = slices.length - 1; i >= 0; i--) {
-        if (slices[i].isFullThread || !slices[i].isReply) {
+        const slice = slices[i]
+        if (slice.isFullThread || !slice.isReply) {
           continue
         }
 
-        const item = slices[i].rootItem
+        const item = slice.rootItem
         const isRepost = Boolean(item.reason)
-        if (!isRepost && (item.post.likeCount || 0) < repliesThreshold) {
+        if (isRepost) {
+          continue
+        }
+        if ((item.post.likeCount || 0) < minLikes) {
+          slices.splice(i, 1)
+        } else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
           slices.splice(i, 1)
         }
       }
diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts
new file mode 100644
index 000000000..1ae925123
--- /dev/null
+++ b/src/lib/api/feed/author.ts
@@ -0,0 +1,45 @@
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedGetAuthorFeed as GetAuthorFeed,
+} from '@atproto/api'
+import {RootStoreModel} from 'state/index'
+import {FeedAPI, FeedAPIResponse} from './types'
+
+export class AuthorFeedAPI implements FeedAPI {
+  cursor: string | undefined
+
+  constructor(
+    public rootStore: RootStoreModel,
+    public params: GetAuthorFeed.QueryParams,
+  ) {}
+
+  reset() {
+    this.cursor = undefined
+  }
+
+  async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
+    const res = await this.rootStore.agent.getAuthorFeed({
+      ...this.params,
+      limit: 1,
+    })
+    return res.data.feed[0]
+  }
+
+  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
+    const res = await this.rootStore.agent.getAuthorFeed({
+      ...this.params,
+      cursor: this.cursor,
+      limit,
+    })
+    if (res.success) {
+      this.cursor = res.data.cursor
+      return {
+        cursor: res.data.cursor,
+        feed: res.data.feed,
+      }
+    }
+    return {
+      feed: [],
+    }
+  }
+}
diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts
new file mode 100644
index 000000000..d05d5acd6
--- /dev/null
+++ b/src/lib/api/feed/custom.ts
@@ -0,0 +1,52 @@
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedGetFeed as GetCustomFeed,
+} from '@atproto/api'
+import {RootStoreModel} from 'state/index'
+import {FeedAPI, FeedAPIResponse} from './types'
+
+export class CustomFeedAPI implements FeedAPI {
+  cursor: string | undefined
+
+  constructor(
+    public rootStore: RootStoreModel,
+    public params: GetCustomFeed.QueryParams,
+  ) {}
+
+  reset() {
+    this.cursor = undefined
+  }
+
+  async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
+    const res = await this.rootStore.agent.app.bsky.feed.getFeed({
+      ...this.params,
+      limit: 1,
+    })
+    return res.data.feed[0]
+  }
+
+  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
+    const res = await this.rootStore.agent.app.bsky.feed.getFeed({
+      ...this.params,
+      cursor: this.cursor,
+      limit,
+    })
+    if (res.success) {
+      this.cursor = res.data.cursor
+      // NOTE
+      // some custom feeds fail to enforce the pagination limit
+      // so we manually truncate here
+      // -prf
+      if (res.data.feed.length > limit) {
+        res.data.feed = res.data.feed.slice(0, limit)
+      }
+      return {
+        cursor: res.data.cursor,
+        feed: res.data.feed,
+      }
+    }
+    return {
+      feed: [],
+    }
+  }
+}
diff --git a/src/lib/api/feed/following.ts b/src/lib/api/feed/following.ts
new file mode 100644
index 000000000..f14807a57
--- /dev/null
+++ b/src/lib/api/feed/following.ts
@@ -0,0 +1,37 @@
+import {AppBskyFeedDefs} from '@atproto/api'
+import {RootStoreModel} from 'state/index'
+import {FeedAPI, FeedAPIResponse} from './types'
+
+export class FollowingFeedAPI implements FeedAPI {
+  cursor: string | undefined
+
+  constructor(public rootStore: RootStoreModel) {}
+
+  reset() {
+    this.cursor = undefined
+  }
+
+  async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
+    const res = await this.rootStore.agent.getTimeline({
+      limit: 1,
+    })
+    return res.data.feed[0]
+  }
+
+  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
+    const res = await this.rootStore.agent.getTimeline({
+      cursor: this.cursor,
+      limit,
+    })
+    if (res.success) {
+      this.cursor = res.data.cursor
+      return {
+        cursor: res.data.cursor,
+        feed: res.data.feed,
+      }
+    }
+    return {
+      feed: [],
+    }
+  }
+}
diff --git a/src/lib/api/feed/likes.ts b/src/lib/api/feed/likes.ts
new file mode 100644
index 000000000..e9bb14b0b
--- /dev/null
+++ b/src/lib/api/feed/likes.ts
@@ -0,0 +1,45 @@
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedGetActorLikes as GetActorLikes,
+} from '@atproto/api'
+import {RootStoreModel} from 'state/index'
+import {FeedAPI, FeedAPIResponse} from './types'
+
+export class LikesFeedAPI implements FeedAPI {
+  cursor: string | undefined
+
+  constructor(
+    public rootStore: RootStoreModel,
+    public params: GetActorLikes.QueryParams,
+  ) {}
+
+  reset() {
+    this.cursor = undefined
+  }
+
+  async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
+    const res = await this.rootStore.agent.getActorLikes({
+      ...this.params,
+      limit: 1,
+    })
+    return res.data.feed[0]
+  }
+
+  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
+    const res = await this.rootStore.agent.getActorLikes({
+      ...this.params,
+      cursor: this.cursor,
+      limit,
+    })
+    if (res.success) {
+      this.cursor = res.data.cursor
+      return {
+        cursor: res.data.cursor,
+        feed: res.data.feed,
+      }
+    }
+    return {
+      feed: [],
+    }
+  }
+}
diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts
new file mode 100644
index 000000000..51a619589
--- /dev/null
+++ b/src/lib/api/feed/merge.ts
@@ -0,0 +1,236 @@
+import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
+import shuffle from 'lodash.shuffle'
+import {RootStoreModel} from 'state/index'
+import {timeout} from 'lib/async/timeout'
+import {bundleAsync} from 'lib/async/bundle'
+import {feedUriToHref} from 'lib/strings/url-helpers'
+import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types'
+
+const REQUEST_WAIT_MS = 500 // 500ms
+const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
+
+export class MergeFeedAPI implements FeedAPI {
+  following: MergeFeedSource_Following
+  customFeeds: MergeFeedSource_Custom[] = []
+  feedCursor = 0
+  itemCursor = 0
+  sampleCursor = 0
+
+  constructor(public rootStore: RootStoreModel) {
+    this.following = new MergeFeedSource_Following(this.rootStore)
+  }
+
+  reset() {
+    this.following = new MergeFeedSource_Following(this.rootStore)
+    this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
+    this.feedCursor = 0
+    this.itemCursor = 0
+    this.sampleCursor = 0
+  }
+
+  async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
+    const res = await this.rootStore.agent.getTimeline({
+      limit: 1,
+    })
+    return res.data.feed[0]
+  }
+
+  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
+    // we capture here to ensure the data has loaded
+    this._captureFeedsIfNeeded()
+
+    const promises = []
+
+    // always keep following topped up
+    if (this.following.numReady < limit) {
+      promises.push(this.following.fetchNext(30))
+    }
+
+    // pick the next feeds to sample from
+    const feeds = this.customFeeds.slice(this.feedCursor, this.feedCursor + 3)
+    this.feedCursor += 3
+    if (this.feedCursor > this.customFeeds.length) {
+      this.feedCursor = 0
+    }
+
+    // top up the feeds
+    for (const feed of feeds) {
+      if (feed.numReady < 5) {
+        promises.push(feed.fetchNext(10))
+      }
+    }
+
+    // wait for requests (all capped at a fixed timeout)
+    await Promise.all(promises)
+
+    // assemble a response by sampling from feeds with content
+    const posts: AppBskyFeedDefs.FeedViewPost[] = []
+    while (posts.length < limit) {
+      let slice = this.sampleItem()
+      if (slice[0]) {
+        posts.push(slice[0])
+      } else {
+        break
+      }
+    }
+
+    return {
+      cursor: posts.length ? 'fake' : undefined,
+      feed: posts,
+    }
+  }
+
+  sampleItem() {
+    const i = this.itemCursor++
+    const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0)
+    const canSample = candidateFeeds.length > 0
+    const hasFollows = this.following.numReady > 0
+
+    // this condition establishes the frequency that custom feeds are woven into follows
+    const shouldSample =
+      i >= 15 && candidateFeeds.length >= 2 && (i % 4 === 0 || i % 5 === 0)
+
+    if (!canSample && !hasFollows) {
+      // no data available
+      return []
+    }
+    if (shouldSample || !hasFollows) {
+      // time to sample, or the user isnt following anybody
+      return candidateFeeds[this.sampleCursor++ % candidateFeeds.length].take(1)
+    }
+    // not time to sample
+    return this.following.take(1)
+  }
+
+  _captureFeedsIfNeeded() {
+    if (!this.rootStore.preferences.homeFeedMergeFeedEnabled) {
+      return
+    }
+    if (this.customFeeds.length === 0) {
+      this.customFeeds = shuffle(
+        this.rootStore.me.savedFeeds.all.map(
+          feed =>
+            new MergeFeedSource_Custom(
+              this.rootStore,
+              feed.uri,
+              feed.displayName,
+            ),
+        ),
+      )
+    }
+  }
+}
+
+class MergeFeedSource {
+  sourceInfo: FeedSourceInfo | undefined
+  cursor: string | undefined = undefined
+  queue: AppBskyFeedDefs.FeedViewPost[] = []
+  hasMore = true
+
+  constructor(public rootStore: RootStoreModel) {}
+
+  get numReady() {
+    return this.queue.length
+  }
+
+  get needsFetch() {
+    return this.hasMore && this.queue.length === 0
+  }
+
+  reset() {
+    this.cursor = undefined
+    this.queue = []
+    this.hasMore = true
+  }
+
+  take(n: number): AppBskyFeedDefs.FeedViewPost[] {
+    return this.queue.splice(0, n)
+  }
+
+  async fetchNext(n: number) {
+    await Promise.race([this._fetchNextInner(n), timeout(REQUEST_WAIT_MS)])
+  }
+
+  _fetchNextInner = bundleAsync(async (n: number) => {
+    const res = await this._getFeed(this.cursor, n)
+    if (res.success) {
+      this.cursor = res.data.cursor
+      if (res.data.feed.length) {
+        this.queue = this.queue.concat(res.data.feed)
+      } else {
+        this.hasMore = false
+      }
+    } else {
+      this.hasMore = false
+    }
+  })
+
+  protected _getFeed(
+    _cursor: string | undefined,
+    _limit: number,
+  ): Promise<AppBskyFeedGetTimeline.Response> {
+    throw new Error('Must be overridden')
+  }
+}
+
+class MergeFeedSource_Following extends MergeFeedSource {
+  async fetchNext(n: number) {
+    return this._fetchNextInner(n)
+  }
+
+  protected async _getFeed(
+    cursor: string | undefined,
+    limit: number,
+  ): Promise<AppBskyFeedGetTimeline.Response> {
+    const res = await this.rootStore.agent.getTimeline({cursor, limit})
+    // filter out mutes pre-emptively to ensure better mixing
+    res.data.feed = res.data.feed.filter(
+      post => !post.post.author.viewer?.muted,
+    )
+    return res
+  }
+}
+
+class MergeFeedSource_Custom extends MergeFeedSource {
+  minDate: Date
+
+  constructor(
+    public rootStore: RootStoreModel,
+    public feedUri: string,
+    public feedDisplayName: string,
+  ) {
+    super(rootStore)
+    this.sourceInfo = {
+      displayName: feedDisplayName,
+      uri: feedUriToHref(feedUri),
+    }
+    this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
+  }
+
+  protected async _getFeed(
+    cursor: string | undefined,
+    limit: number,
+  ): Promise<AppBskyFeedGetTimeline.Response> {
+    const res = await this.rootStore.agent.app.bsky.feed.getFeed({
+      cursor,
+      limit,
+      feed: this.feedUri,
+    })
+    // NOTE
+    // some custom feeds fail to enforce the pagination limit
+    // so we manually truncate here
+    // -prf
+    if (limit && res.data.feed.length > limit) {
+      res.data.feed = res.data.feed.slice(0, limit)
+    }
+    // filter out older posts
+    res.data.feed = res.data.feed.filter(
+      post => new Date(post.post.indexedAt) > this.minDate,
+    )
+    // attach source info
+    for (const post of res.data.feed) {
+      post.__source = this.sourceInfo
+    }
+    return res
+  }
+}
diff --git a/src/lib/api/feed/types.ts b/src/lib/api/feed/types.ts
new file mode 100644
index 000000000..006344334
--- /dev/null
+++ b/src/lib/api/feed/types.ts
@@ -0,0 +1,17 @@
+import {AppBskyFeedDefs} from '@atproto/api'
+
+export interface FeedAPIResponse {
+  cursor?: string
+  feed: AppBskyFeedDefs.FeedViewPost[]
+}
+
+export interface FeedAPI {
+  reset(): void
+  peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost>
+  fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse>
+}
+
+export interface FeedSourceInfo {
+  uri: string
+  displayName: string
+}
diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx
index 233f8a473..fef7be2f3 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, Circle} from 'react-native-svg'
+import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg'
 
 export function GridIcon({
   style,
@@ -884,45 +884,7 @@ export function HandIcon({
   )
 }
 
-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({
+export function HashtagIcon({
   style,
   size,
   strokeWidth = 1.5,
@@ -934,26 +896,16 @@ export function SatelliteDishIcon({
   return (
     <Svg
       fill="none"
-      viewBox="0 0 22 22"
-      strokeWidth={strokeWidth}
       stroke="currentColor"
+      viewBox="0 0 30 30"
+      strokeWidth={strokeWidth}
       width={size}
       height={size}
       style={style}>
-      <Path d="M 12.705346,15.777547 C 14.4635,17.5315 14.7526,17.8509 14.9928,18.1812 c 0.2139,0.2943 0.3371,0.5275 0.3889,0.6822 C 14.0859,19.5872 12.5926,20 11,20 6.02944,20 2,15.9706 2,11 2,9.4151 2.40883,7.9285 3.12619,6.63699 3.304,6.69748 3.56745,6.84213 3.89275,7.08309 4.3705644,7.4380098 4.7486794,7.8160923 6.4999995,9.5689376 8.2513197,11.321783 10.947192,14.023595 12.705346,15.777547 Z" />
-      <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"
-        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"
-        strokeLinecap="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"
-      />
+      <Path d="M2 10H28" strokeLinecap="round" />
+      <Path d="M2 20H28" strokeLinecap="round" />
+      <Path d="M11 3L9 27" strokeLinecap="round" />
+      <Path d="M21 3L19 27" strokeLinecap="round" />
     </Svg>
   )
 }
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 7159bcb51..cc7a468e9 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -9,7 +9,6 @@ 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/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index b509aad01..671dc9781 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -129,6 +129,15 @@ export function listUriToHref(url: string): string {
   }
 }
 
+export function feedUriToHref(url: string): string {
+  try {
+    const {hostname, rkey} = new AtUri(url)
+    return `/profile/${hostname}/feed/${rkey}`
+  } catch {
+    return ''
+  }
+}
+
 export function getYoutubeVideoId(link: string): string | undefined {
   let url
   try {
diff --git a/src/routes.ts b/src/routes.ts
index 45a8fa572..7c356eb1b 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -4,7 +4,6 @@ export const router = new Router({
   Home: '/',
   Search: '/search',
   Feeds: '/feeds',
-  DiscoverFeeds: '/search/feeds',
   Notifications: '/notifications',
   Settings: '/settings',
   Moderation: '/moderation',
diff --git a/src/state/models/feeds/multi-feed.ts b/src/state/models/feeds/multi-feed.ts
deleted file mode 100644
index 95574fb56..000000000
--- a/src/state/models/feeds/multi-feed.ts
+++ /dev/null
@@ -1,227 +0,0 @@
-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 './posts-slice'
-import {makeProfileLink} from 'lib/routes/links'
-
-const FEED_PAGE_SIZE = 10
-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: makeProfileLink(feedInfo.data.creator, 'feed', urip.rkey),
-      })
-    }
-    if (!this.hasMore && this.hasContent) {
-      // only show if hasContent to avoid double discover-feed links
-      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 latest in the active feeds
-   */
-  loadLatest() {
-    for (const feed of this.feeds) {
-      /* dont await */ feed.refresh()
-    }
-  }
-
-  /**
-   * 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/posts-slice.ts b/src/state/models/feeds/posts-slice.ts
index 16e4eef15..2501cef6f 100644
--- a/src/state/models/feeds/posts-slice.ts
+++ b/src/state/models/feeds/posts-slice.ts
@@ -2,6 +2,7 @@ import {makeAutoObservable} from 'mobx'
 import {RootStoreModel} from '../root-store'
 import {FeedViewPostsSlice} from 'lib/api/feed-manip'
 import {PostsFeedItemModel} from './post'
+import {FeedSourceInfo} from 'lib/api/feed/types'
 
 export class PostsFeedSliceModel {
   // ui state
@@ -9,9 +10,11 @@ export class PostsFeedSliceModel {
 
   // data
   items: PostsFeedItemModel[] = []
+  source: FeedSourceInfo | undefined
 
   constructor(public rootStore: RootStoreModel, slice: FeedViewPostsSlice) {
     this._reactKey = slice._reactKey
+    this.source = slice.source
     for (let i = 0; i < slice.items.length; i++) {
       this.items.push(
         new PostsFeedItemModel(
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index c88249c8f..d4e62533e 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -14,6 +14,13 @@ import {PostsFeedSliceModel} from './posts-slice'
 import {track} from 'lib/analytics/analytics'
 import {FeedViewPostsSlice} from 'lib/api/feed-manip'
 
+import {FeedAPI, FeedAPIResponse} from 'lib/api/feed/types'
+import {FollowingFeedAPI} from 'lib/api/feed/following'
+import {AuthorFeedAPI} from 'lib/api/feed/author'
+import {LikesFeedAPI} from 'lib/api/feed/likes'
+import {CustomFeedAPI} from 'lib/api/feed/custom'
+import {MergeFeedAPI} from 'lib/api/feed/merge'
+
 const PAGE_SIZE = 30
 
 type Options = {
@@ -27,6 +34,7 @@ type Options = {
 type QueryParams =
   | GetTimeline.QueryParams
   | GetAuthorFeed.QueryParams
+  | GetActorLikes.QueryParams
   | GetCustomFeed.QueryParams
 
 export class PostsFeedModel {
@@ -41,8 +49,8 @@ export class PostsFeedModel {
   loadMoreError = ''
   params: QueryParams
   hasMore = true
-  loadMoreCursor: string | undefined
   pollCursor: string | undefined
+  api: FeedAPI
   tuner = new FeedTuner()
   pageSize = PAGE_SIZE
   options: Options = {}
@@ -50,7 +58,7 @@ export class PostsFeedModel {
   // used to linearize async modifications to state
   lock = new AwaitLock()
 
-  // used to track if what's hot is coming up empty
+  // used to track if a feed is coming up empty
   emptyFetches = 0
 
   // data
@@ -58,7 +66,7 @@ export class PostsFeedModel {
 
   constructor(
     public rootStore: RootStoreModel,
-    public feedType: 'home' | 'author' | 'custom' | 'likes',
+    public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes',
     params: QueryParams,
     options?: Options,
   ) {
@@ -67,12 +75,33 @@ export class PostsFeedModel {
       {
         rootStore: false,
         params: false,
-        loadMoreCursor: false,
       },
       {autoBind: true},
     )
     this.params = params
     this.options = options || {}
+    if (feedType === 'home') {
+      this.api = new MergeFeedAPI(rootStore)
+    } else if (feedType === 'following') {
+      this.api = new FollowingFeedAPI(rootStore)
+    } else if (feedType === 'author') {
+      this.api = new AuthorFeedAPI(
+        rootStore,
+        params as GetAuthorFeed.QueryParams,
+      )
+    } else if (feedType === 'likes') {
+      this.api = new LikesFeedAPI(
+        rootStore,
+        params as GetActorLikes.QueryParams,
+      )
+    } else if (feedType === 'custom') {
+      this.api = new CustomFeedAPI(
+        rootStore,
+        params as GetCustomFeed.QueryParams,
+      )
+    } else {
+      this.api = new FollowingFeedAPI(rootStore)
+    }
   }
 
   get hasContent() {
@@ -105,7 +134,6 @@ export class PostsFeedModel {
     this.hasLoaded = false
     this.error = ''
     this.hasMore = true
-    this.loadMoreCursor = undefined
     this.pollCursor = undefined
     this.slices = []
     this.tuner.reset()
@@ -113,6 +141,8 @@ export class PostsFeedModel {
 
   get feedTuners() {
     const areRepliesEnabled = this.rootStore.preferences.homeFeedRepliesEnabled
+    const areRepliesByFollowedOnlyEnabled =
+      this.rootStore.preferences.homeFeedRepliesByFollowedOnlyEnabled
     const repliesThreshold = this.rootStore.preferences.homeFeedRepliesThreshold
     const areRepostsEnabled = this.rootStore.preferences.homeFeedRepostsEnabled
     const areQuotePostsEnabled =
@@ -126,7 +156,7 @@ export class PostsFeedModel {
         ),
       ]
     }
-    if (this.feedType === 'home') {
+    if (this.feedType === 'home' || this.feedType === 'following') {
       const feedTuners = []
 
       if (areRepostsEnabled) {
@@ -136,7 +166,13 @@ export class PostsFeedModel {
       }
 
       if (areRepliesEnabled) {
-        feedTuners.push(FeedTuner.likedRepliesOnly({repliesThreshold}))
+        feedTuners.push(
+          FeedTuner.thresholdRepliesOnly({
+            userDid: this.rootStore.session.data?.did || '',
+            minLikes: repliesThreshold,
+            followedOnly: areRepliesByFollowedOnlyEnabled,
+          }),
+        )
       } else {
         feedTuners.push(FeedTuner.removeReplies)
       }
@@ -161,10 +197,11 @@ export class PostsFeedModel {
     await this.lock.acquireAsync()
     try {
       this.setHasNewLatest(false)
+      this.api.reset()
       this.tuner.reset()
       this._xLoading(isRefreshing)
       try {
-        const res = await this._getFeed({limit: this.pageSize})
+        const res = await this.api.fetchNext({limit: this.pageSize})
         await this._replaceAll(res)
         this._xIdle()
       } catch (e: any) {
@@ -201,8 +238,7 @@ export class PostsFeedModel {
       }
       this._xLoading()
       try {
-        const res = await this._getFeed({
-          cursor: this.loadMoreCursor,
+        const res = await this.api.fetchNext({
           limit: this.pageSize,
         })
         await this._appendAll(res)
@@ -231,53 +267,15 @@ export class PostsFeedModel {
   }
 
   /**
-   * Update content in-place
-   */
-  update = bundleAsync(async () => {
-    await this.lock.acquireAsync()
-    try {
-      if (!this.slices.length) {
-        return
-      }
-      this._xLoading()
-      let numToFetch = this.slices.length
-      let cursor
-      try {
-        do {
-          const res: GetTimeline.Response = await this._getFeed({
-            cursor,
-            limit: Math.min(numToFetch, 100),
-          })
-          if (res.data.feed.length === 0) {
-            break // sanity check
-          }
-          this._updateAll(res)
-          numToFetch -= res.data.feed.length
-          cursor = res.data.cursor
-        } while (cursor && numToFetch > 0)
-        this._xIdle()
-      } catch (e: any) {
-        this._xIdle() // don't bubble the error to the user
-        this.rootStore.log.error('FeedView: Failed to update', {
-          params: this.params,
-          e,
-        })
-      }
-    } finally {
-      this.lock.release()
-    }
-  })
-
-  /**
    * Check if new posts are available
    */
   async checkForLatest() {
     if (!this.hasLoaded || this.hasNewLatest || this.isLoading) {
       return
     }
-    const res = await this._getFeed({limit: 1})
-    if (res.data.feed[0]) {
-      const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
+    const post = await this.api.peekLatest()
+    if (post) {
+      const slices = this.tuner.tune([post], this.feedTuners, {
         dryRun: true,
       })
       if (slices[0]) {
@@ -345,33 +343,27 @@ export class PostsFeedModel {
   // helper functions
   // =
 
-  async _replaceAll(
-    res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
-  ) {
-    this.pollCursor = res.data.feed[0]?.post.uri
+  async _replaceAll(res: FeedAPIResponse) {
+    this.pollCursor = res.feed[0]?.post.uri
     return this._appendAll(res, true)
   }
 
-  async _appendAll(
-    res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
-    replace = false,
-  ) {
-    this.loadMoreCursor = res.data.cursor
-    this.hasMore = !!this.loadMoreCursor
+  async _appendAll(res: FeedAPIResponse, replace = false) {
+    this.hasMore = !!res.cursor
     if (replace) {
       this.emptyFetches = 0
     }
 
     this.rootStore.me.follows.hydrateProfiles(
-      res.data.feed.map(item => item.post.author),
+      res.feed.map(item => item.post.author),
     )
-    for (const item of res.data.feed) {
+    for (const item of res.feed) {
       this.rootStore.posts.fromFeedItem(item)
     }
 
     const slices = this.options.isSimpleFeed
-      ? res.data.feed.map(item => new FeedViewPostsSlice([item]))
-      : this.tuner.tune(res.data.feed, this.feedTuners)
+      ? res.feed.map(item => new FeedViewPostsSlice([item]))
+      : this.tuner.tune(res.feed, this.feedTuners)
 
     const toAppend: PostsFeedSliceModel[] = []
     for (const slice of slices) {
@@ -401,54 +393,4 @@ export class PostsFeedModel {
       }
     })
   }
-
-  _updateAll(
-    res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
-  ) {
-    for (const item of res.data.feed) {
-      this.rootStore.posts.fromFeedItem(item)
-      const existingSlice = this.slices.find(slice =>
-        slice.containsUri(item.post.uri),
-      )
-      if (existingSlice) {
-        const existingItem = existingSlice.items.find(
-          item2 => item2.post.uri === item.post.uri,
-        )
-        if (existingItem) {
-          existingItem.copyMetrics(item)
-        }
-      }
-    }
-  }
-
-  protected async _getFeed(
-    params: QueryParams,
-  ): Promise<
-    GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response
-  > {
-    params = Object.assign({}, this.params, params)
-    if (this.feedType === 'home') {
-      return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams)
-    } else if (this.feedType === 'custom') {
-      const res = await this.rootStore.agent.app.bsky.feed.getFeed(
-        params as GetCustomFeed.QueryParams,
-      )
-      // NOTE
-      // some custom feeds fail to enforce the pagination limit
-      // so we manually truncate here
-      // -prf
-      if (params.limit && res.data.feed.length > params.limit) {
-        res.data.feed = res.data.feed.slice(0, params.limit)
-      }
-      return res
-    } else if (this.feedType === 'author') {
-      return this.rootStore.agent.getAuthorFeed(
-        params as GetAuthorFeed.QueryParams,
-      )
-    } else {
-      return this.rootStore.agent.getActorLikes(
-        params as GetActorLikes.QueryParams,
-      )
-    }
-  }
 }
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index 6204e0d10..1a81072a2 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -139,7 +139,7 @@ export class RootStoreModel {
     this.agent = agent
     applyDebugHeader(this.agent)
     this.me.clear()
-    /* dont await */ this.preferences.sync()
+    await this.preferences.sync()
     await this.me.load()
     if (!hadSession) {
       await resetNavigation()
diff --git a/src/state/models/ui/my-feeds.ts b/src/state/models/ui/my-feeds.ts
new file mode 100644
index 000000000..f9ad06f77
--- /dev/null
+++ b/src/state/models/ui/my-feeds.ts
@@ -0,0 +1,157 @@
+import {makeAutoObservable} from 'mobx'
+import {FeedsDiscoveryModel} from '../discovery/feeds'
+import {CustomFeedModel} from '../feeds/custom-feed'
+import {RootStoreModel} from '../root-store'
+
+export type MyFeedsItem =
+  | {
+      _reactKey: string
+      type: 'spinner'
+    }
+  | {
+      _reactKey: string
+      type: 'discover-feeds-loading'
+    }
+  | {
+      _reactKey: string
+      type: 'error'
+      error: string
+    }
+  | {
+      _reactKey: string
+      type: 'saved-feeds-header'
+    }
+  | {
+      _reactKey: string
+      type: 'saved-feed'
+      feed: CustomFeedModel
+    }
+  | {
+      _reactKey: string
+      type: 'saved-feeds-load-more'
+    }
+  | {
+      _reactKey: string
+      type: 'discover-feeds-header'
+    }
+  | {
+      _reactKey: string
+      type: 'discover-feeds-no-results'
+    }
+  | {
+      _reactKey: string
+      type: 'discover-feed'
+      feed: CustomFeedModel
+    }
+
+export class MyFeedsUIModel {
+  discovery: FeedsDiscoveryModel
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(this)
+    this.discovery = new FeedsDiscoveryModel(this.rootStore)
+  }
+
+  get saved() {
+    return this.rootStore.me.savedFeeds
+  }
+
+  get isRefreshing() {
+    return !this.saved.isLoading && this.saved.isRefreshing
+  }
+
+  get isLoading() {
+    return this.saved.isLoading || this.discovery.isLoading
+  }
+
+  async setup() {
+    if (!this.saved.hasLoaded) {
+      await this.saved.refresh()
+    }
+    if (!this.discovery.hasLoaded) {
+      await this.discovery.refresh()
+    }
+  }
+
+  async refresh() {
+    return Promise.all([this.saved.refresh(), this.discovery.refresh()])
+  }
+
+  async loadMore() {
+    return this.discovery.loadMore()
+  }
+
+  get items() {
+    let items: MyFeedsItem[] = []
+
+    items.push({
+      _reactKey: '__saved_feeds_header__',
+      type: 'saved-feeds-header',
+    })
+    if (this.saved.isLoading) {
+      items.push({
+        _reactKey: '__saved_feeds_loading__',
+        type: 'spinner',
+      })
+    } else if (this.saved.hasError) {
+      items.push({
+        _reactKey: '__saved_feeds_error__',
+        type: 'error',
+        error: this.saved.error,
+      })
+    } else {
+      const savedSorted = this.saved.all
+        .slice()
+        .sort((a, b) => a.displayName.localeCompare(b.displayName))
+      items = items.concat(
+        savedSorted.map(feed => ({
+          _reactKey: `saved-${feed.uri}`,
+          type: 'saved-feed',
+          feed,
+        })),
+      )
+      items.push({
+        _reactKey: '__saved_feeds_load_more__',
+        type: 'saved-feeds-load-more',
+      })
+    }
+
+    items.push({
+      _reactKey: '__discover_feeds_header__',
+      type: 'discover-feeds-header',
+    })
+    if (this.discovery.isLoading && !this.discovery.hasContent) {
+      items.push({
+        _reactKey: '__discover_feeds_loading__',
+        type: 'discover-feeds-loading',
+      })
+    } else if (this.discovery.hasError) {
+      items.push({
+        _reactKey: '__discover_feeds_error__',
+        type: 'error',
+        error: this.discovery.error,
+      })
+    } else if (this.discovery.isEmpty) {
+      items.push({
+        _reactKey: '__discover_feeds_no_results__',
+        type: 'discover-feeds-no-results',
+      })
+    } else {
+      items = items.concat(
+        this.discovery.feeds.map(feed => ({
+          _reactKey: `discover-${feed.uri}`,
+          type: 'discover-feed',
+          feed,
+        })),
+      )
+      if (this.discovery.isLoading) {
+        items.push({
+          _reactKey: '__discover_feeds_loading_more__',
+          type: 'spinner',
+        })
+      }
+    }
+
+    return items
+  }
+}
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index 64ab4ecba..7232a7b74 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -50,9 +50,11 @@ export class PreferencesModel {
   pinnedFeeds: string[] = []
   birthDate: Date | undefined = undefined
   homeFeedRepliesEnabled: boolean = true
-  homeFeedRepliesThreshold: number = 2
+  homeFeedRepliesByFollowedOnlyEnabled: boolean = true
+  homeFeedRepliesThreshold: number = 0
   homeFeedRepostsEnabled: boolean = true
   homeFeedQuotePostsEnabled: boolean = true
+  homeFeedMergeFeedEnabled: boolean = false
   requireAltTextEnabled: boolean = false
 
   // used to linearize async modifications to state
@@ -78,9 +80,12 @@ export class PreferencesModel {
       savedFeeds: this.savedFeeds,
       pinnedFeeds: this.pinnedFeeds,
       homeFeedRepliesEnabled: this.homeFeedRepliesEnabled,
+      homeFeedRepliesByFollowedOnlyEnabled:
+        this.homeFeedRepliesByFollowedOnlyEnabled,
       homeFeedRepliesThreshold: this.homeFeedRepliesThreshold,
       homeFeedRepostsEnabled: this.homeFeedRepostsEnabled,
       homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled,
+      homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled,
       requireAltTextEnabled: this.requireAltTextEnabled,
     }
   }
@@ -148,6 +153,14 @@ export class PreferencesModel {
       ) {
         this.homeFeedRepliesEnabled = v.homeFeedRepliesEnabled
       }
+      // check if home feed replies "followed only" are enabled in preferences, then hydrate
+      if (
+        hasProp(v, 'homeFeedRepliesByFollowedOnlyEnabled') &&
+        typeof v.homeFeedRepliesByFollowedOnlyEnabled === 'boolean'
+      ) {
+        this.homeFeedRepliesByFollowedOnlyEnabled =
+          v.homeFeedRepliesByFollowedOnlyEnabled
+      }
       // check if home feed replies threshold is enabled in preferences, then hydrate
       if (
         hasProp(v, 'homeFeedRepliesThreshold') &&
@@ -169,6 +182,13 @@ export class PreferencesModel {
       ) {
         this.homeFeedQuotePostsEnabled = v.homeFeedQuotePostsEnabled
       }
+      // check if home feed mergefeed is enabled in preferences, then hydrate
+      if (
+        hasProp(v, 'homeFeedMergeFeedEnabled') &&
+        typeof v.homeFeedMergeFeedEnabled === 'boolean'
+      ) {
+        this.homeFeedMergeFeedEnabled = v.homeFeedMergeFeedEnabled
+      }
       // check if requiring alt text is enabled in preferences, then hydrate
       if (
         hasProp(v, 'requireAltTextEnabled') &&
@@ -449,6 +469,11 @@ export class PreferencesModel {
     this.homeFeedRepliesEnabled = !this.homeFeedRepliesEnabled
   }
 
+  toggleHomeFeedRepliesByFollowedOnlyEnabled() {
+    this.homeFeedRepliesByFollowedOnlyEnabled =
+      !this.homeFeedRepliesByFollowedOnlyEnabled
+  }
+
   setHomeFeedRepliesThreshold(threshold: number) {
     this.homeFeedRepliesThreshold = threshold
   }
@@ -461,6 +486,10 @@ export class PreferencesModel {
     this.homeFeedQuotePostsEnabled = !this.homeFeedQuotePostsEnabled
   }
 
+  toggleHomeFeedMergeFeedEnabled() {
+    this.homeFeedMergeFeedEnabled = !this.homeFeedMergeFeedEnabled
+  }
+
   toggleRequireAltTextEnabled() {
     this.requireAltTextEnabled = !this.requireAltTextEnabled
   }
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
index 11951b0ee..8525426bf 100644
--- a/src/state/models/ui/profile.ts
+++ b/src/state/models/ui/profile.ts
@@ -240,13 +240,6 @@ export class ProfileUiModel {
       .catch(err => this.rootStore.log.error('Failed to fetch lists', err))
   }
 
-  async update() {
-    const view = this.currentView
-    if (view instanceof PostsFeedModel) {
-      await view.update()
-    }
-  }
-
   async refresh() {
     await Promise.all([this.profile.refresh(), this.currentView.refresh()])
   }
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index d457d7136..4ca22282d 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -21,11 +21,13 @@ export const Feed = observer(function Feed({
   scrollElRef,
   onPressTryAgain,
   onScroll,
+  ListHeaderComponent,
 }: {
   view: NotificationsFeedModel
   scrollElRef?: MutableRefObject<FlatList<any> | null>
   onPressTryAgain?: () => void
   onScroll?: OnScrollCb
+  ListHeaderComponent?: () => JSX.Element
 }) {
   const pal = usePalette('default')
   const [isPTRing, setIsPTRing] = React.useState(false)
@@ -142,6 +144,7 @@ export const Feed = observer(function Feed({
           data={data}
           keyExtractor={item => item._reactKey}
           renderItem={renderItem}
+          ListHeaderComponent={ListHeaderComponent}
           ListFooterComponent={FeedFooter}
           refreshControl={
             <RefreshControl
@@ -156,6 +159,8 @@ export const Feed = observer(function Feed({
           onScroll={onScroll}
           scrollEventThrottle={100}
           contentContainerStyle={s.contentContainer}
+          // @ts-ignore our .web version only -prf
+          desktopFixedHeight
         />
       ) : null}
     </View>
diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx
index 0083e953b..02aa623cc 100644
--- a/src/view/com/pager/FeedsTabBar.web.tsx
+++ b/src/view/com/pager/FeedsTabBar.web.tsx
@@ -12,15 +12,17 @@ import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
 export const FeedsTabBar = observer(function FeedsTabBarImpl(
   props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
 ) {
-  const {isMobile} = useWebMediaQueries()
+  const {isMobile, isTablet} = useWebMediaQueries()
   if (isMobile) {
     return <FeedsTabBarMobile {...props} />
+  } else if (isTablet) {
+    return <FeedsTabBarTablet {...props} />
   } else {
-    return <FeedsTabBarDesktop {...props} />
+    return null
   }
 })
 
-const FeedsTabBarDesktop = observer(function FeedsTabBarDesktopImpl(
+const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl(
   props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
 ) {
   const store = useStores()
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index 5ce2906b3..30a712541 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -9,8 +9,8 @@ import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
-import {CogIcon} from 'lib/icons'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
 import {s} from 'lib/styles'
 import {HITSLOP_10} from 'lib/constants'
 
@@ -67,12 +67,15 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
         </Text>
         <View style={[pal.view]}>
           <Link
-            href="/settings/saved-feeds"
+            href="/settings/home-feed"
             hitSlop={HITSLOP_10}
             accessibilityRole="button"
-            accessibilityLabel="Edit Saved Feeds"
-            accessibilityHint="Opens screen to edit Saved Feeds">
-            <CogIcon size={21} strokeWidth={2} style={pal.textLight} />
+            accessibilityLabel="Home Feed Preferences"
+            accessibilityHint="">
+            <FontAwesomeIcon
+              icon="sliders"
+              style={pal.textLight as FontAwesomeIconStyle}
+            />
           </Link>
         </View>
       </View>
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 7a5a45771..1cc177d17 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -357,6 +357,8 @@ export const PostThread = observer(function PostThread({
       }
       onScrollToIndexFailed={onScrollToIndexFailed}
       style={s.hContentRegion}
+      // @ts-ignore our .web version only -prf
+      desktopFixedHeight
     />
   )
 })
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 5b5fee0ca..37c7ece47 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -483,15 +483,6 @@ export const PostThreadItem = observer(function PostThreadItem({
                   />
                 </ContentHider>
               )}
-              {needsTranslation && (
-                <View style={[pal.borderDark, styles.translateLink]}>
-                  <Link href={translatorUrl} title="Translate">
-                    <Text type="sm" style={pal.link}>
-                      Translate this post
-                    </Text>
-                  </Link>
-                </View>
-              )}
               <PostCtrls
                 itemUri={itemUri}
                 itemCid={itemCid}
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 0855f25bf..d7559e3c4 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -1,4 +1,4 @@
-import React, {useState, useMemo} from 'react'
+import React, {useState} from 'react'
 import {
   ActivityIndicator,
   Linking,
@@ -28,7 +28,7 @@ import {PreviewableUserAvatar} from '../util/UserAvatar'
 import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
-import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
+import {getTranslatorLink} from '../../../locale/helpers'
 import {makeProfileLink} from 'lib/routes/links'
 
 export const Post = observer(function PostImpl({
@@ -116,12 +116,6 @@ const PostLoaded = observer(function PostLoadedImpl({
   }
 
   const translatorUrl = getTranslatorLink(record?.text || '')
-  const needsTranslation = useMemo(
-    () =>
-      store.preferences.contentLanguages.length > 0 &&
-      !isPostInLanguage(item.post, store.preferences.contentLanguages),
-    [item.post, store.preferences.contentLanguages],
-  )
 
   const onPressReply = React.useCallback(() => {
     store.shell.openComposer({
@@ -256,15 +250,6 @@ const PostLoaded = observer(function PostLoadedImpl({
                 />
               </ContentHider>
             ) : null}
-            {needsTranslation && (
-              <View style={[pal.borderDark, styles.translateLink]}>
-                <Link href={translatorUrl} title="Translate">
-                  <Text type="sm" style={pal.link}>
-                    Translate this post
-                  </Text>
-                </Link>
-              </View>
-            )}
           </ContentHider>
           <PostCtrls
             itemUri={itemUri}
@@ -322,9 +307,6 @@ const styles = StyleSheet.create({
     alignItems: 'center',
     flexWrap: 'wrap',
   },
-  translateLink: {
-    marginBottom: 12,
-  },
   replyLine: {
     position: 'absolute',
     left: 36,
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index bc7b7a7e6..59ab28d72 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -8,6 +8,7 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {PostsFeedItemModel} from 'state/models/feeds/post'
+import {FeedSourceInfo} from 'lib/api/feed/types'
 import {Link, DesktopWebTextLink} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserInfoText} from '../util/UserInfoText'
@@ -26,17 +27,19 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
-import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
+import {getTranslatorLink} from '../../../locale/helpers'
 import {makeProfileLink} from 'lib/routes/links'
 import {isEmbedByEmbedder} from 'lib/embeds'
 
 export const FeedItem = observer(function FeedItemImpl({
   item,
+  source,
   isThreadChild,
   isThreadLastChild,
   isThreadParent,
 }: {
   item: PostsFeedItemModel
+  source?: FeedSourceInfo
   isThreadChild?: boolean
   isThreadLastChild?: boolean
   isThreadParent?: boolean
@@ -62,12 +65,6 @@ export const FeedItem = observer(function FeedItemImpl({
     return urip.hostname
   }, [record?.reply])
   const translatorUrl = getTranslatorLink(record?.text || '')
-  const needsTranslation = useMemo(
-    () =>
-      store.preferences.contentLanguages.length > 0 &&
-      !isPostInLanguage(item.post, store.preferences.contentLanguages),
-    [item.post, store.preferences.contentLanguages],
-  )
 
   const onPressReply = React.useCallback(() => {
     track('FeedItem:PostReply')
@@ -179,7 +176,27 @@ export const FeedItem = observer(function FeedItemImpl({
         </View>
 
         <View style={{paddingTop: 12}}>
-          {item.reasonRepost && (
+          {source ? (
+            <Link
+              title={sanitizeDisplayName(source.displayName)}
+              href={source.uri}>
+              <Text
+                type="sm-bold"
+                style={pal.textLight}
+                lineHeight={1.2}
+                numberOfLines={1}>
+                From{' '}
+                <DesktopWebTextLink
+                  type="sm-bold"
+                  style={pal.textLight}
+                  lineHeight={1.2}
+                  numberOfLines={1}
+                  text={sanitizeDisplayName(source.displayName)}
+                  href={source.uri}
+                />
+              </Text>
+            </Link>
+          ) : item.reasonRepost ? (
             <Link
               style={styles.includeReason}
               href={makeProfileLink(item.reasonRepost.by)}
@@ -188,10 +205,10 @@ export const FeedItem = observer(function FeedItemImpl({
               )}>
               <FontAwesomeIcon
                 icon="retweet"
-                style={[
-                  styles.includeReasonIcon,
-                  {color: pal.colors.textLight} as FontAwesomeIconStyle,
-                ]}
+                style={{
+                  marginRight: 4,
+                  color: pal.colors.textLight,
+                }}
               />
               <Text
                 type="sm-bold"
@@ -212,7 +229,7 @@ export const FeedItem = observer(function FeedItemImpl({
                 />
               </Text>
             </Link>
-          )}
+          ) : null}
         </View>
       </View>
 
@@ -304,15 +321,6 @@ export const FeedItem = observer(function FeedItemImpl({
                 />
               </ContentHider>
             ) : null}
-            {needsTranslation && (
-              <View style={[pal.borderDark, styles.translateLink]}>
-                <Link href={translatorUrl} title="Translate">
-                  <Text type="sm" style={pal.link}>
-                    Translate this post
-                  </Text>
-                </Link>
-              </View>
-            )}
           </ContentHider>
           <PostCtrls
             itemUri={itemUri}
@@ -362,12 +370,9 @@ const styles = StyleSheet.create({
   includeReason: {
     flexDirection: 'row',
     marginTop: 2,
-    marginBottom: 4,
+    marginBottom: 2,
     marginLeft: -20,
   },
-  includeReasonIcon: {
-    marginRight: 4,
-  },
   layout: {
     flexDirection: 'row',
     marginTop: 1,
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index 47313ee27..1d26f6cbd 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -28,6 +28,7 @@ export const FeedSlice = observer(function FeedSliceImpl({
         <FeedItem
           key={slice.items[0]._reactKey}
           item={slice.items[0]}
+          source={slice.source}
           isThreadParent={slice.isThreadParentAt(0)}
           isThreadChild={slice.isThreadChildAt(0)}
         />
@@ -55,6 +56,7 @@ export const FeedSlice = observer(function FeedSliceImpl({
         <FeedItem
           key={item._reactKey}
           item={item}
+          source={i === 0 ? slice.source : undefined}
           isThreadParent={slice.isThreadParentAt(i)}
           isThreadChild={slice.isThreadChildAt(i)}
           isThreadLastChild={
diff --git a/src/view/com/posts/FollowingEmptyState.tsx b/src/view/com/posts/FollowingEmptyState.tsx
index 4491b2526..a73ffb68b 100644
--- a/src/view/com/posts/FollowingEmptyState.tsx
+++ b/src/view/com/posts/FollowingEmptyState.tsx
@@ -28,7 +28,7 @@ export function FollowingEmptyState() {
   }, [navigation])
 
   const onPressDiscoverFeeds = React.useCallback(() => {
-    navigation.navigate('DiscoverFeeds')
+    navigation.navigate('Feeds')
   }, [navigation])
 
   return (
diff --git a/src/view/com/posts/MultiFeed.tsx b/src/view/com/posts/MultiFeed.tsx
deleted file mode 100644
index 9c8f4f246..000000000
--- a/src/view/com/posts/MultiFeed.tsx
+++ /dev/null
@@ -1,256 +0,0 @@
-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/analytics'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useTheme} from 'lib/ThemeContext'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {CogIcon} from 'lib/icons'
-
-export const MultiFeed = observer(function Feed({
-  multifeed,
-  style,
-  scrollElRef,
-  onScroll,
-  scrollEventThrottle,
-  testID,
-  headerOffset = 0,
-  extraData,
-}: {
-  multifeed: PostsMultiFeedModel
-  style?: StyleProp<ViewStyle>
-  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 theme = useTheme()
-  const {isMobile} = useWebMediaQueries()
-  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') {
-        if (!isMobile) {
-          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>
-              <DiscoverLink />
-            </>
-          )
-        }
-        return (
-          <>
-            <View style={[styles.header, pal.border]} />
-            <DiscoverLink />
-          </>
-        )
-      } 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} />
-      } 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 <DiscoverLink />
-      }
-      return null
-    },
-    [pal, isMobile],
-  )
-
-  const ListFooter = 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={ListFooter}
-          refreshControl={
-            <RefreshControl
-              refreshing={isRefreshing}
-              onRefresh={onRefresh}
-              tintColor={pal.colors.text}
-              titleColor={pal.colors.text}
-              progressViewOffset={headerOffset}
-            />
-          }
-          contentContainerStyle={s.contentContainer}
-          style={[{paddingTop: headerOffset}, pal.view, 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>
-  )
-})
-
-function DiscoverLink() {
-  const pal = usePalette('default')
-  return (
-    <Link style={[styles.discoverLink, pal.viewLight]} href="/search/feeds">
-      <FontAwesomeIcon icon="search" size={18} color={pal.colors.text} />
-      <Text type="xl-medium" style={pal.text}>
-        Discover new feeds
-      </Text>
-    </Link>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    height: '100%',
-  },
-  header: {
-    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,
-    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,
-  },
-  discoverLink: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    borderRadius: 8,
-    paddingHorizontal: 14,
-    paddingVertical: 12,
-    marginHorizontal: 8,
-    marginVertical: 8,
-    gap: 8,
-  },
-  loadMore: {
-    paddingTop: 10,
-  },
-})
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 321b6ab63..d4df2bec4 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -26,6 +26,7 @@ import {useStores, RootStoreModel} from 'state/index'
 import {convertBskyAppUrlIfNeeded, isExternalUrl} from 'lib/strings/url-helpers'
 import {isAndroid, isDesktopWeb} from 'platform/detection'
 import {sanitizeUrl} from '@braintree/sanitize-url'
+import {PressableWithHover} from './PressableWithHover'
 import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
 
 type Event =
@@ -38,6 +39,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> {
   href?: string
   title?: string
   children?: React.ReactNode
+  hoverStyle?: StyleProp<ViewStyle>
   noFeedback?: boolean
   asAnchor?: boolean
   anchorNoUnderline?: boolean
@@ -112,8 +114,9 @@ export const Link = observer(function Link({
     props.accessibilityLabel = title
   }
 
+  const Com = props.hoverStyle ? PressableWithHover : Pressable
   return (
-    <Pressable
+    <Com
       testID={testID}
       style={style}
       onPress={onPress}
@@ -123,7 +126,7 @@ export const Link = observer(function Link({
       href={asAnchor ? sanitizeUrl(href) : undefined}
       {...props}>
       {children ? children : <Text>{title || 'link'}</Text>}
-    </Pressable>
+    </Com>
   )
 })
 
@@ -137,6 +140,7 @@ export const TextLink = observer(function TextLink({
   lineHeight,
   dataSet,
   title,
+  onPress,
 }: {
   testID?: string
   type?: TypographyVariant
@@ -154,9 +158,14 @@ export const TextLink = observer(function TextLink({
 
   props.onPress = React.useCallback(
     (e?: Event) => {
+      if (onPress) {
+        e?.preventDefault?.()
+        // @ts-ignore function signature differs by platform -prf
+        return onPress()
+      }
       return onPressInner(store, navigation, sanitizeUrl(href), e)
     },
-    [store, navigation, href],
+    [onPress, store, navigation, href],
   )
   const hrefAttrs = useMemo(() => {
     const isExternal = isExternalUrl(href)
diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx
index bf39fd50c..d7ab1be54 100644
--- a/src/view/com/util/LoadingPlaceholder.tsx
+++ b/src/view/com/util/LoadingPlaceholder.tsx
@@ -174,6 +174,60 @@ export function ProfileCardFeedLoadingPlaceholder() {
   )
 }
 
+export function FeedLoadingPlaceholder({
+  style,
+}: {
+  style?: StyleProp<ViewStyle>
+}) {
+  const pal = usePalette('default')
+  return (
+    <View
+      style={[
+        {paddingHorizontal: 12, paddingVertical: 18, borderTopWidth: 1},
+        pal.border,
+        style,
+      ]}>
+      <View style={[pal.view, {flexDirection: 'row', marginBottom: 10}]}>
+        <LoadingPlaceholder
+          width={36}
+          height={36}
+          style={[styles.avatar, {borderRadius: 6}]}
+        />
+        <View style={[s.flex1]}>
+          <LoadingPlaceholder width={100} height={8} style={[s.mt5, s.mb10]} />
+          <LoadingPlaceholder width={120} height={8} />
+        </View>
+      </View>
+      <View style={{paddingHorizontal: 5}}>
+        <LoadingPlaceholder
+          width={260}
+          height={8}
+          style={{marginVertical: 12}}
+        />
+        <LoadingPlaceholder width={120} height={8} />
+      </View>
+    </View>
+  )
+}
+
+export function FeedFeedLoadingPlaceholder() {
+  return (
+    <>
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+      <FeedLoadingPlaceholder />
+    </>
+  )
+}
+
 const styles = StyleSheet.create({
   loadingPlaceholder: {
     borderRadius: 6,
diff --git a/src/view/com/util/SimpleViewHeader.tsx b/src/view/com/util/SimpleViewHeader.tsx
new file mode 100644
index 000000000..4eff38a31
--- /dev/null
+++ b/src/view/com/util/SimpleViewHeader.tsx
@@ -0,0 +1,105 @@
+import React from 'react'
+import {observer} from 'mobx-react-lite'
+import {
+  StyleProp,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useNavigation} from '@react-navigation/native'
+import {CenteredView} from './Views'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {NavigationProp} from 'lib/routes/types'
+
+const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
+
+export const SimpleViewHeader = observer(function SimpleViewHeaderImpl({
+  showBackButton = true,
+  style,
+  children,
+}: React.PropsWithChildren<{
+  showBackButton?: boolean
+  style?: StyleProp<ViewStyle>
+}>) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const navigation = useNavigation<NavigationProp>()
+  const {track} = useAnalytics()
+  const {isMobile} = useWebMediaQueries()
+  const canGoBack = navigation.canGoBack()
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
+  }, [navigation])
+
+  const onPressMenu = React.useCallback(() => {
+    track('ViewHeader:MenuButtonClicked')
+    store.shell.openDrawer()
+  }, [track, store])
+
+  const Container = isMobile ? View : CenteredView
+  return (
+    <Container style={[styles.header, isMobile && styles.headerMobile, style]}>
+      {showBackButton ? (
+        <TouchableOpacity
+          testID="viewHeaderDrawerBtn"
+          onPress={canGoBack ? onPressBack : onPressMenu}
+          hitSlop={BACK_HITSLOP}
+          style={canGoBack ? styles.backBtn : styles.backBtnWide}
+          accessibilityRole="button"
+          accessibilityLabel={canGoBack ? 'Back' : 'Menu'}
+          accessibilityHint="">
+          {canGoBack ? (
+            <FontAwesomeIcon
+              size={18}
+              icon="angle-left"
+              style={[styles.backIcon, pal.text]}
+            />
+          ) : (
+            <FontAwesomeIcon
+              size={18}
+              icon="bars"
+              style={[styles.backIcon, pal.textLight]}
+            />
+          )}
+        </TouchableOpacity>
+      ) : null}
+      {children}
+    </Container>
+  )
+})
+
+const styles = StyleSheet.create({
+  header: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 18,
+    paddingVertical: 12,
+    width: '100%',
+  },
+  headerMobile: {
+    paddingHorizontal: 12,
+    paddingVertical: 10,
+  },
+  backBtn: {
+    width: 30,
+    height: 30,
+  },
+  backBtnWide: {
+    width: 30,
+    height: 30,
+    paddingHorizontal: 6,
+  },
+  backIcon: {
+    marginTop: 6,
+  },
+})
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 0f34f75aa..7a42ab4d3 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -118,7 +118,7 @@ export function UserAvatar({
       return {
         width: size,
         height: size,
-        borderRadius: 8,
+        borderRadius: size > 32 ? 8 : 3,
       }
     }
     return {
diff --git a/src/view/com/util/forms/SearchInput.tsx b/src/view/com/util/forms/SearchInput.tsx
new file mode 100644
index 000000000..c1eb82bd4
--- /dev/null
+++ b/src/view/com/util/forms/SearchInput.tsx
@@ -0,0 +1,104 @@
+import React from 'react'
+import {
+  StyleProp,
+  StyleSheet,
+  TextInput,
+  TouchableOpacity,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {MagnifyingGlassIcon} from 'lib/icons'
+import {useTheme} from 'lib/ThemeContext'
+import {usePalette} from 'lib/hooks/usePalette'
+
+interface Props {
+  query: string
+  setIsInputFocused?: (v: boolean) => void
+  onChangeQuery: (v: string) => void
+  onPressCancelSearch: () => void
+  onSubmitQuery: () => void
+  style?: StyleProp<ViewStyle>
+}
+export function SearchInput({
+  query,
+  setIsInputFocused,
+  onChangeQuery,
+  onPressCancelSearch,
+  onSubmitQuery,
+  style,
+}: Props) {
+  const theme = useTheme()
+  const pal = usePalette('default')
+  const textInput = React.useRef<TextInput>(null)
+
+  const onPressCancelSearchInner = React.useCallback(() => {
+    onPressCancelSearch()
+    textInput.current?.blur()
+  }, [onPressCancelSearch, textInput])
+
+  return (
+    <View style={[pal.viewLight, styles.container, style]}>
+      <MagnifyingGlassIcon style={[pal.icon, styles.icon]} size={21} />
+      <TextInput
+        testID="searchTextInput"
+        ref={textInput}
+        placeholder="Search"
+        placeholderTextColor={pal.colors.textLight}
+        selectTextOnFocus
+        returnKeyType="search"
+        value={query}
+        style={[pal.text, styles.input]}
+        keyboardAppearance={theme.colorScheme}
+        onFocus={() => setIsInputFocused?.(true)}
+        onBlur={() => setIsInputFocused?.(false)}
+        onChangeText={onChangeQuery}
+        onSubmitEditing={onSubmitQuery}
+        accessibilityRole="search"
+        accessibilityLabel="Search"
+        accessibilityHint=""
+        autoCorrect={false}
+        autoCapitalize="none"
+      />
+      {query ? (
+        <TouchableOpacity
+          onPress={onPressCancelSearchInner}
+          accessibilityRole="button"
+          accessibilityLabel="Clear search query"
+          accessibilityHint="">
+          <FontAwesomeIcon
+            icon="xmark"
+            size={16}
+            style={pal.textLight as FontAwesomeIconStyle}
+          />
+        </TouchableOpacity>
+      ) : undefined}
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+    borderRadius: 30,
+    paddingHorizontal: 12,
+    paddingVertical: 8,
+  },
+  icon: {
+    marginRight: 6,
+    alignSelf: 'center',
+  },
+  input: {
+    flex: 1,
+    fontSize: 17,
+    minWidth: 0, // overflow mitigation for firefox
+  },
+  cancelBtn: {
+    paddingLeft: 10,
+  },
+})
diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx
index ae9cb9361..6b73edd4b 100644
--- a/src/view/com/util/load-latest/LoadLatestBtn.tsx
+++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx
@@ -1 +1,86 @@
-export * from './LoadLatestBtnMobile'
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {clamp} from 'lodash'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {colors} from 'lib/styles'
+import {HITSLOP_20} from 'lib/constants'
+
+export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
+  onPress,
+  label,
+  showIndicator,
+}: {
+  onPress: () => void
+  label: string
+  showIndicator: boolean
+  minimalShellMode?: boolean // NOTE not used on mobile -prf
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+  const {isDesktop, isTablet, isMobile} = useWebMediaQueries()
+  const safeAreaInsets = useSafeAreaInsets()
+  return (
+    <TouchableOpacity
+      style={[
+        styles.loadLatest,
+        isDesktop && styles.loadLatestDesktop,
+        isTablet && styles.loadLatestTablet,
+        pal.borderDark,
+        pal.view,
+        isMobile &&
+          !store.shell.minimalShellMode && {
+            bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30),
+          },
+      ]}
+      onPress={onPress}
+      hitSlop={HITSLOP_20}
+      accessibilityRole="button"
+      accessibilityLabel={label}
+      accessibilityHint="">
+      <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} />
+      {showIndicator && <View style={[styles.indicator, pal.borderDark]} />}
+    </TouchableOpacity>
+  )
+})
+
+const styles = StyleSheet.create({
+  loadLatest: {
+    position: 'absolute',
+    left: 18,
+    bottom: 35,
+    borderWidth: 1,
+    width: 52,
+    height: 52,
+    borderRadius: 26,
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  loadLatestTablet: {
+    // @ts-ignore web only
+    left: '50vw',
+    // @ts-ignore web only -prf
+    transform: 'translateX(-282px)',
+  },
+  loadLatestDesktop: {
+    // @ts-ignore web only
+    left: '50vw',
+    // @ts-ignore web only -prf
+    transform: 'translateX(-382px)',
+  },
+  indicator: {
+    position: 'absolute',
+    top: 3,
+    right: 3,
+    backgroundColor: colors.blue3,
+    width: 12,
+    height: 12,
+    borderRadius: 6,
+    borderWidth: 1,
+  },
+})
diff --git a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx
deleted file mode 100644
index 83c696f7e..000000000
--- a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import React from 'react'
-import {StyleSheet, TouchableOpacity} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {Text} from '../text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {LoadLatestBtn as LoadLatestBtnMobile} from './LoadLatestBtnMobile'
-import {HITSLOP_20} from 'lib/constants'
-
-export const LoadLatestBtn = ({
-  onPress,
-  label,
-  showIndicator,
-  minimalShellMode,
-}: {
-  onPress: () => void
-  label: string
-  showIndicator: boolean
-  minimalShellMode?: boolean
-}) => {
-  const pal = usePalette('default')
-  const {isMobile} = useWebMediaQueries()
-  if (isMobile) {
-    return (
-      <LoadLatestBtnMobile
-        onPress={onPress}
-        label={label}
-        showIndicator={showIndicator}
-      />
-    )
-  }
-  return (
-    <>
-      {showIndicator && (
-        <TouchableOpacity
-          style={[
-            pal.view,
-            pal.borderDark,
-            styles.loadLatestCentered,
-            minimalShellMode && styles.loadLatestCenteredMinimal,
-          ]}
-          onPress={onPress}
-          hitSlop={HITSLOP_20}
-          accessibilityRole="button"
-          accessibilityLabel={label}
-          accessibilityHint="">
-          <Text type="md-bold" style={pal.text}>
-            {label}
-          </Text>
-        </TouchableOpacity>
-      )}
-      <TouchableOpacity
-        style={[pal.view, pal.borderDark, styles.loadLatest]}
-        onPress={onPress}
-        hitSlop={HITSLOP_20}
-        accessibilityRole="button"
-        accessibilityLabel={label}
-        accessibilityHint="">
-        <Text type="md-bold" style={pal.text}>
-          <FontAwesomeIcon
-            icon="angle-up"
-            size={21}
-            style={[pal.text, styles.icon]}
-          />
-        </Text>
-      </TouchableOpacity>
-    </>
-  )
-}
-
-const styles = StyleSheet.create({
-  loadLatest: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    position: 'absolute',
-    // @ts-ignore web only
-    left: '50vw',
-    // @ts-ignore web only -prf
-    transform: 'translateX(-282px)',
-    bottom: 40,
-    width: 54,
-    height: 54,
-    borderRadius: 30,
-    borderWidth: 1,
-  },
-  icon: {
-    position: 'relative',
-    top: 2,
-  },
-  loadLatestCentered: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    position: 'absolute',
-    // @ts-ignore web only
-    left: '50vw',
-    // @ts-ignore web only -prf
-    transform: 'translateX(-50%)',
-    top: 60,
-    paddingHorizontal: 24,
-    paddingVertical: 14,
-    borderRadius: 30,
-    borderWidth: 1,
-  },
-  loadLatestCenteredMinimal: {
-    top: 20,
-  },
-})
diff --git a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx
deleted file mode 100644
index 3e8add5e9..000000000
--- a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {clamp} from 'lodash'
-import {useStores} from 'state/index'
-import {usePalette} from 'lib/hooks/usePalette'
-import {colors} from 'lib/styles'
-import {HITSLOP_20} from 'lib/constants'
-
-export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
-  onPress,
-  label,
-  showIndicator,
-}: {
-  onPress: () => void
-  label: string
-  showIndicator: boolean
-  minimalShellMode?: boolean // NOTE not used on mobile -prf
-}) {
-  const store = useStores()
-  const pal = usePalette('default')
-  const safeAreaInsets = useSafeAreaInsets()
-  return (
-    <TouchableOpacity
-      style={[
-        styles.loadLatest,
-        pal.borderDark,
-        pal.view,
-        !store.shell.minimalShellMode && {
-          bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30),
-        },
-      ]}
-      onPress={onPress}
-      hitSlop={HITSLOP_20}
-      accessibilityRole="button"
-      accessibilityLabel={label}
-      accessibilityHint="">
-      <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} />
-      {showIndicator && <View style={[styles.indicator, pal.borderDark]} />}
-    </TouchableOpacity>
-  )
-})
-
-const styles = StyleSheet.create({
-  loadLatest: {
-    position: 'absolute',
-    left: 18,
-    bottom: 35,
-    borderWidth: 1,
-    width: 52,
-    height: 52,
-    borderRadius: 26,
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-  },
-  indicator: {
-    position: 'absolute',
-    top: 3,
-    right: 3,
-    backgroundColor: colors.blue3,
-    width: 12,
-    height: 12,
-    borderRadius: 6,
-    borderWidth: 1,
-  },
-})
diff --git a/src/view/index.ts b/src/view/index.ts
index 2e4c08ec7..2fdc34e7b 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -13,6 +13,7 @@ import {faArrowRightFromBracket} from '@fortawesome/free-solid-svg-icons/faArrow
 import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket'
 import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare'
 import {faArrowRotateLeft} from '@fortawesome/free-solid-svg-icons/faArrowRotateLeft'
+import {faArrowTrendUp} from '@fortawesome/free-solid-svg-icons/faArrowTrendUp'
 import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate'
 import {faAt} from '@fortawesome/free-solid-svg-icons/faAt'
 import {faBars} from '@fortawesome/free-solid-svg-icons/faBars'
@@ -24,6 +25,7 @@ import {faBookmark as farBookmark} from '@fortawesome/free-regular-svg-icons/faB
 import {faCalendar as farCalendar} from '@fortawesome/free-regular-svg-icons/faCalendar'
 import {faCamera} from '@fortawesome/free-solid-svg-icons/faCamera'
 import {faCheck} from '@fortawesome/free-solid-svg-icons/faCheck'
+import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'
 import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle'
 import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck'
 import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck'
@@ -41,6 +43,7 @@ import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation'
 import {faEye} from '@fortawesome/free-solid-svg-icons/faEye'
 import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
 import {faFaceSmile} from '@fortawesome/free-regular-svg-icons/faFaceSmile'
+import {faFire} from '@fortawesome/free-solid-svg-icons/faFire'
 import {faFloppyDisk} from '@fortawesome/free-regular-svg-icons/faFloppyDisk'
 import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
 import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
@@ -54,15 +57,18 @@ import {faImage} from '@fortawesome/free-solid-svg-icons/faImage'
 import {faInfo} from '@fortawesome/free-solid-svg-icons/faInfo'
 import {faLanguage} from '@fortawesome/free-solid-svg-icons/faLanguage'
 import {faLink} from '@fortawesome/free-solid-svg-icons/faLink'
+import {faList} from '@fortawesome/free-solid-svg-icons/faList'
 import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl'
 import {faLock} from '@fortawesome/free-solid-svg-icons/faLock'
 import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass'
 import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage'
 import {faNoteSticky} from '@fortawesome/free-solid-svg-icons/faNoteSticky'
+import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
 import {faPaste} from '@fortawesome/free-regular-svg-icons/faPaste'
 import {faPen} from '@fortawesome/free-solid-svg-icons/faPen'
 import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
 import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare'
+import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
 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'
@@ -77,6 +83,7 @@ import {faSliders} from '@fortawesome/free-solid-svg-icons/faSliders'
 import {faSquare} from '@fortawesome/free-regular-svg-icons/faSquare'
 import {faSquareCheck} from '@fortawesome/free-regular-svg-icons/faSquareCheck'
 import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus'
+import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack'
 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'
@@ -88,11 +95,6 @@ import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark'
 import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash'
 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'
-import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
-import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack'
-import {faList} from '@fortawesome/free-solid-svg-icons/faList'
-import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'
 
 export function setup() {
   library.add(
@@ -109,6 +111,7 @@ export function setup() {
     faArrowUpFromBracket,
     faArrowUpRightFromSquare,
     faArrowRotateLeft,
+    faArrowTrendUp,
     faArrowsRotate,
     faAt,
     faBan,
@@ -120,6 +123,7 @@ export function setup() {
     farCalendar,
     faCamera,
     faCheck,
+    faChevronRight,
     faCircle,
     faCircleCheck,
     farCircleCheck,
@@ -137,6 +141,7 @@ export function setup() {
     faExclamation,
     farEyeSlash,
     faFaceSmile,
+    faFire,
     faFloppyDisk,
     faGear,
     faGlobe,
@@ -150,15 +155,18 @@ export function setup() {
     faInfo,
     faLanguage,
     faLink,
+    faList,
     faListUl,
     faLock,
     faMagnifyingGlass,
     faMessage,
     faNoteSticky,
     faPaste,
+    faPause,
     faPen,
     faPenNib,
     faPenToSquare,
+    faPlay,
     faPlus,
     faQuoteLeft,
     faReply,
@@ -180,14 +188,10 @@ export function setup() {
     faUserPlus,
     faUserXmark,
     faUsersSlash,
+    faThumbtack,
     faTicket,
     faTrashCan,
-    faThumbtack,
     faX,
     faXmark,
-    faPlay,
-    faPause,
-    faList,
-    faChevronRight,
   )
 }
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx
index af4d01843..eaa21f292 100644
--- a/src/view/screens/CustomFeed.tsx
+++ b/src/view/screens/CustomFeed.tsx
@@ -1,7 +1,7 @@
 import React, {useMemo, useRef} from 'react'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {useNavigation} from '@react-navigation/native'
+import {useNavigation, useIsFocused} from '@react-navigation/native'
 import {usePalette} from 'lib/hooks/usePalette'
 import {HeartIcon, HeartIconSolid} from 'lib/icons'
 import {CommonNavigatorParams} from 'lib/routes/types'
@@ -14,11 +14,8 @@ 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 {sanitizeHandle} from 'lib/strings/handles'
 import {TextLink} from 'view/com/util/Link'
-import {UserAvatar} from 'view/com/util/UserAvatar'
-import {ViewHeader} from 'view/com/util/ViewHeader'
+import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
 import {Button} from 'view/com/util/forms/Button'
 import {Text} from 'view/com/util/text/Text'
 import * as Toast from 'view/com/util/Toast'
@@ -34,7 +31,6 @@ import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
 import {EmptyState} from 'view/com/util/EmptyState'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
-import {makeProfileLink} from 'lib/routes/links'
 import {resolveName} from 'lib/api'
 import {CenteredView} from 'view/com/util/Views'
 import {NavigationProp} from 'lib/routes/types'
@@ -125,7 +121,10 @@ export const CustomFeedScreenInner = observer(
   }: Props & {feedOwnerDid: string}) {
     const store = useStores()
     const pal = usePalette('default')
-    const {isTabletOrDesktop} = useWebMediaQueries()
+    const palInverted = usePalette('inverted')
+    const navigation = useNavigation<NavigationProp>()
+    const isScreenFocused = useIsFocused()
+    const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
     const {track} = useAnalytics()
     const {rkey, name: handleOrDid} = route.params
     const uri = useMemo(
@@ -186,6 +185,10 @@ export const CustomFeedScreenInner = observer(
       })
     }, [store, currentFeed])
 
+    const onPressViewAuthor = React.useCallback(() => {
+      navigation.navigate('Profile', {name: handleOrDid})
+    }, [handleOrDid, navigation])
+
     const onPressShare = React.useCallback(() => {
       const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`)
       shareUrl(url)
@@ -210,9 +213,40 @@ export const CustomFeedScreenInner = observer(
       store.shell.openComposer({})
     }, [store])
 
+    const onSoftReset = React.useCallback(() => {
+      if (isScreenFocused) {
+        onScrollToTop()
+        algoFeed.refresh()
+      }
+    }, [isScreenFocused, onScrollToTop, algoFeed])
+
+    // fires when page within screen is activated/deactivated
+    React.useEffect(() => {
+      if (!isScreenFocused) {
+        return
+      }
+
+      const softResetSub = store.onScreenSoftReset(onSoftReset)
+      return () => {
+        softResetSub.remove()
+      }
+    }, [store, onSoftReset, isScreenFocused])
+
     const dropdownItems: DropdownItem[] = React.useMemo(() => {
       let items: DropdownItem[] = [
         {
+          testID: 'feedHeaderDropdownViewAuthorBtn',
+          label: 'View author',
+          onPress: onPressViewAuthor,
+          icon: {
+            ios: {
+              name: 'person',
+            },
+            android: '',
+            web: ['far', 'user'],
+          },
+        },
+        {
           testID: 'feedHeaderDropdownToggleSavedBtn',
           label: currentFeed?.isSaved
             ? 'Remove from my feeds'
@@ -260,232 +294,12 @@ export const CustomFeedScreenInner = observer(
         },
       ]
       return items
-    }, [currentFeed?.isSaved, onToggleSaved, onPressReport, onPressShare])
-
-    const renderHeaderBtns = React.useCallback(() => {
-      return (
-        <View style={styles.headerBtns}>
-          <Button
-            type="default-light"
-            testID="toggleLikeBtn"
-            accessibilityLabel="Like this feed"
-            accessibilityHint=""
-            onPress={onToggleLiked}>
-            {currentFeed?.isLiked ? (
-              <HeartIconSolid size={19} style={styles.liked} />
-            ) : (
-              <HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
-            )}
-          </Button>
-          {currentFeed?.isSaved ? (
-            <Button
-              type="default-light"
-              accessibilityLabel={
-                isPinned ? 'Unpin this feed' : 'Pin this feed'
-              }
-              accessibilityHint=""
-              onPress={onTogglePinned}>
-              <FontAwesomeIcon
-                icon="thumb-tack"
-                size={17}
-                color={isPinned ? colors.blue3 : pal.colors.textLight}
-                style={styles.top1}
-              />
-            </Button>
-          ) : undefined}
-          {!currentFeed?.isSaved ? (
-            <Button
-              type="default-light"
-              onPress={onToggleSaved}
-              accessibilityLabel="Add to my feeds"
-              accessibilityHint=""
-              style={styles.headerAddBtn}>
-              <FontAwesomeIcon icon="plus" color={pal.colors.link} size={19} />
-              <Text type="xl-medium" style={pal.link}>
-                Add to My Feeds
-              </Text>
-            </Button>
-          ) : null}
-          <NativeDropdown testID="feedHeaderDropdownBtn" items={dropdownItems}>
-            <View
-              style={{
-                paddingLeft: currentFeed?.isSaved ? 12 : 6,
-                paddingRight: 12,
-                paddingVertical: 8,
-              }}>
-              <FontAwesomeIcon
-                icon="ellipsis"
-                size={20}
-                color={pal.colors.textLight}
-              />
-            </View>
-          </NativeDropdown>
-        </View>
-      )
     }, [
-      pal,
       currentFeed?.isSaved,
-      currentFeed?.isLiked,
-      isPinned,
-      onToggleSaved,
-      onTogglePinned,
-      onToggleLiked,
-      dropdownItems,
-    ])
-
-    const renderListHeaderComponent = React.useCallback(() => {
-      return (
-        <>
-          <View style={[styles.header, pal.border]}>
-            <View style={s.flex1}>
-              <Text
-                testID="feedName"
-                type="title-xl"
-                style={[pal.text, s.bold]}>
-                {currentFeed?.displayName}
-              </Text>
-              {currentFeed && (
-                <Text type="md" style={[pal.textLight]} numberOfLines={1}>
-                  by{' '}
-                  {currentFeed.data.creator.did === store.me.did ? (
-                    'you'
-                  ) : (
-                    <TextLink
-                      text={sanitizeHandle(
-                        currentFeed.data.creator.handle,
-                        '@',
-                      )}
-                      href={makeProfileLink(currentFeed.data.creator)}
-                      style={[pal.textLight]}
-                    />
-                  )}
-                </Text>
-              )}
-              {isTabletOrDesktop && (
-                <View style={[styles.headerBtns, styles.headerBtnsDesktop]}>
-                  <Button
-                    type={currentFeed?.isSaved ? 'default' : 'inverted'}
-                    onPress={onToggleSaved}
-                    accessibilityLabel={
-                      currentFeed?.isSaved
-                        ? 'Unsave this feed'
-                        : 'Save this feed'
-                    }
-                    accessibilityHint=""
-                    label={
-                      currentFeed?.isSaved
-                        ? 'Remove from My Feeds'
-                        : 'Add to My Feeds'
-                    }
-                  />
-                  <Button
-                    type="default"
-                    accessibilityLabel={
-                      isPinned ? 'Unpin this feed' : 'Pin this feed'
-                    }
-                    accessibilityHint=""
-                    onPress={onTogglePinned}>
-                    <FontAwesomeIcon
-                      icon="thumb-tack"
-                      size={15}
-                      color={isPinned ? colors.blue3 : pal.colors.icon}
-                      style={styles.top2}
-                    />
-                  </Button>
-                  <Button
-                    type="default"
-                    accessibilityLabel="Like this feed"
-                    accessibilityHint=""
-                    onPress={onToggleLiked}>
-                    {currentFeed?.isLiked ? (
-                      <HeartIconSolid size={18} style={styles.liked} />
-                    ) : (
-                      <HeartIcon strokeWidth={3} size={18} style={pal.icon} />
-                    )}
-                  </Button>
-                  <Button
-                    type="default"
-                    accessibilityLabel="Share this feed"
-                    accessibilityHint=""
-                    onPress={onPressShare}>
-                    <FontAwesomeIcon
-                      icon="share"
-                      size={18}
-                      color={pal.colors.icon}
-                    />
-                  </Button>
-                  <Button
-                    type="default"
-                    accessibilityLabel="Report this feed"
-                    accessibilityHint=""
-                    onPress={onPressReport}>
-                    <FontAwesomeIcon
-                      icon="circle-exclamation"
-                      size={18}
-                      color={pal.colors.icon}
-                    />
-                  </Button>
-                </View>
-              )}
-            </View>
-            <View>
-              <UserAvatar
-                type="algo"
-                avatar={currentFeed?.data.avatar}
-                size={64}
-              />
-            </View>
-          </View>
-          <View style={styles.headerDetails}>
-            {currentFeed?.data.description ? (
-              <Text style={[pal.text, s.mb10]} numberOfLines={6}>
-                {currentFeed.data.description}
-              </Text>
-            ) : null}
-            <View style={styles.headerDetailsFooter}>
-              {currentFeed ? (
-                <TextLink
-                  type="md-medium"
-                  style={pal.textLight}
-                  href={`/profile/${handleOrDid}/feed/${rkey}/liked-by`}
-                  text={`Liked by ${currentFeed.data.likeCount} ${pluralize(
-                    currentFeed?.data.likeCount || 0,
-                    'user',
-                  )}`}
-                />
-              ) : null}
-            </View>
-          </View>
-          <View
-            style={[
-              styles.fakeSelector,
-              {
-                paddingHorizontal: isTabletOrDesktop ? 16 : 6,
-              },
-              pal.border,
-            ]}>
-            <View
-              style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}>
-              <Text type="md-medium" style={[pal.text]}>
-                Feed
-              </Text>
-            </View>
-          </View>
-        </>
-      )
-    }, [
-      pal,
-      currentFeed,
-      store.me.did,
       onToggleSaved,
-      onToggleLiked,
-      onPressShare,
-      handleOrDid,
       onPressReport,
-      rkey,
-      isPinned,
-      onTogglePinned,
-      isTabletOrDesktop,
+      onPressShare,
+      onPressViewAuthor,
     ])
 
     const renderEmptyState = React.useCallback(() => {
@@ -498,22 +312,100 @@ export const CustomFeedScreenInner = observer(
 
     return (
       <View style={s.hContentRegion}>
-        {!isTabletOrDesktop && (
-          <ViewHeader title="" renderButton={currentFeed && renderHeaderBtns} />
-        )}
+        <SimpleViewHeader
+          showBackButton={isMobile}
+          style={
+            !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]
+          }>
+          <Text type="title-lg" style={styles.headerText} numberOfLines={1}>
+            {currentFeed ? (
+              <TextLink
+                type="title-lg"
+                href="/"
+                style={[pal.text, {fontWeight: 'bold'}]}
+                text={currentFeed?.displayName || ''}
+                onPress={() => store.emitScreenSoftReset()}
+              />
+            ) : (
+              'Loading...'
+            )}
+          </Text>
+          {currentFeed ? (
+            <>
+              <Button
+                type="default-light"
+                testID="toggleLikeBtn"
+                accessibilityLabel="Like this feed"
+                accessibilityHint=""
+                onPress={onToggleLiked}
+                style={styles.headerBtn}>
+                {currentFeed?.isLiked ? (
+                  <HeartIconSolid size={19} style={styles.liked} />
+                ) : (
+                  <HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
+                )}
+              </Button>
+              {currentFeed?.isSaved ? (
+                <Button
+                  type="default-light"
+                  accessibilityLabel={
+                    isPinned ? 'Unpin this feed' : 'Pin this feed'
+                  }
+                  accessibilityHint=""
+                  onPress={onTogglePinned}
+                  style={styles.headerBtn}>
+                  <FontAwesomeIcon
+                    icon="thumb-tack"
+                    size={17}
+                    color={isPinned ? colors.blue3 : pal.colors.textLight}
+                    style={styles.top1}
+                  />
+                </Button>
+              ) : (
+                <Button
+                  type="inverted"
+                  onPress={onToggleSaved}
+                  accessibilityLabel="Add to my feeds"
+                  accessibilityHint=""
+                  style={styles.headerAddBtn}>
+                  <FontAwesomeIcon
+                    icon="plus"
+                    color={palInverted.colors.text}
+                    size={19}
+                  />
+                  <Text type="button" style={palInverted.text}>
+                    Add{!isMobile && ' to My Feeds'}
+                  </Text>
+                </Button>
+              )}
+            </>
+          ) : null}
+          <NativeDropdown testID="feedHeaderDropdownBtn" items={dropdownItems}>
+            <View
+              style={{
+                paddingLeft: 12,
+                paddingRight: isMobile ? 12 : 0,
+              }}>
+              <FontAwesomeIcon
+                icon="ellipsis"
+                size={20}
+                color={pal.colors.textLight}
+              />
+            </View>
+          </NativeDropdown>
+        </SimpleViewHeader>
         <Feed
           scrollElRef={scrollElRef}
           feed={algoFeed}
           onScroll={onMainScroll}
           scrollEventThrottle={100}
-          ListHeaderComponent={renderListHeaderComponent}
           renderEmptyState={renderEmptyState}
           extraData={[uri, isPinned]}
           style={!isTabletOrDesktop ? {flex: 1} : undefined}
         />
         {isScrolledDown ? (
           <LoadLatestBtn
-            onPress={onScrollToTop}
+            onPress={onSoftReset}
             label="Scroll to top"
             showIndicator={false}
           />
@@ -540,36 +432,19 @@ const styles = StyleSheet.create({
     paddingBottom: 16,
     borderTopWidth: 1,
   },
-  headerBtns: {
-    flexDirection: 'row',
-    alignItems: 'center',
+  headerText: {
+    flex: 1,
+    fontWeight: 'bold',
   },
-  headerBtnsDesktop: {
-    marginTop: 8,
-    gap: 4,
+  headerBtn: {
+    paddingVertical: 0,
   },
   headerAddBtn: {
     flexDirection: 'row',
     alignItems: 'center',
     gap: 4,
-    paddingLeft: 4,
-  },
-  headerDetails: {
-    paddingHorizontal: 16,
-    paddingBottom: 16,
-  },
-  headerDetailsFooter: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'space-between',
-  },
-  fakeSelector: {
-    flexDirection: 'row',
-  },
-  fakeSelectorItem: {
-    paddingHorizontal: 12,
-    paddingBottom: 8,
-    borderBottomWidth: 3,
+    paddingVertical: 4,
+    paddingLeft: 10,
   },
   liked: {
     color: colors.red3,
diff --git a/src/view/screens/DiscoverFeeds.tsx b/src/view/screens/DiscoverFeeds.tsx
deleted file mode 100644
index 6aa7a9e31..000000000
--- a/src/view/screens/DiscoverFeeds.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-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 {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {s} from 'lib/styles'
-import {CustomFeedModel} from 'state/models/feeds/custom-feed'
-import {HeaderWithInput} from 'view/com/search/HeaderWithInput'
-import debounce from 'lodash.debounce'
-
-type Props = NativeStackScreenProps<CommonNavigatorParams, 'DiscoverFeeds'>
-export const DiscoverFeedsScreen = withAuthRequired(
-  observer(function DiscoverFeedsScreenImpl({}: Props) {
-    const store = useStores()
-    const pal = usePalette('default')
-    const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store])
-    const {isTabletOrDesktop} = useWebMediaQueries()
-
-    // search stuff
-    const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
-    const [query, setQuery] = React.useState<string>('')
-    const debouncedSearchFeeds = React.useMemo(
-      () => debounce(q => feeds.search(q), 500), // debounce for 500ms
-      [feeds],
-    )
-    const onChangeQuery = React.useCallback(
-      (text: string) => {
-        setQuery(text)
-        if (text.length > 1) {
-          debouncedSearchFeeds(text)
-        } else {
-          feeds.refresh()
-        }
-      },
-      [debouncedSearchFeeds, feeds],
-    )
-    const onPressClearQuery = React.useCallback(() => {
-      setQuery('')
-      feeds.refresh()
-    }, [feeds])
-    const onPressCancelSearch = React.useCallback(() => {
-      setIsInputFocused(false)
-      setQuery('')
-      feeds.refresh()
-    }, [feeds])
-    const onSubmitQuery = React.useCallback(() => {
-      debouncedSearchFeeds(query)
-      debouncedSearchFeeds.flush()
-    }, [debouncedSearchFeeds, query])
-
-    useFocusEffect(
-      React.useCallback(() => {
-        store.shell.setMinimalShellMode(false)
-        if (!feeds.hasLoaded) {
-          feeds.refresh()
-        }
-      }, [store, feeds]),
-    )
-
-    const onRefresh = React.useCallback(() => {
-      feeds.refresh()
-    }, [feeds])
-
-    const renderListEmptyComponent = () => {
-      return (
-        <View style={styles.empty}>
-          <Text type="lg" style={pal.textLight}>
-            {feeds.isLoading
-              ? isTabletOrDesktop
-                ? 'Loading...'
-                : ''
-              : query
-              ? `No results found for "${query}"`
-              : `We can't find any feeds for some reason. This is probably an error - try refreshing!`}
-          </Text>
-        </View>
-      )
-    }
-
-    const renderItem = React.useCallback(
-      ({item}: {item: CustomFeedModel}) => (
-        <CustomFeed
-          key={item.data.uri}
-          item={item}
-          showSaveBtn
-          showDescription
-          showLikes
-        />
-      ),
-      [],
-    )
-
-    return (
-      <CenteredView style={[styles.container, pal.view]}>
-        <View
-          style={[isTabletOrDesktop && styles.containerDesktop, pal.border]}>
-          <ViewHeader title="Discover Feeds" showOnDesktop />
-        </View>
-        <HeaderWithInput
-          isInputFocused={isInputFocused}
-          query={query}
-          setIsInputFocused={setIsInputFocused}
-          onChangeQuery={onChangeQuery}
-          onPressClearQuery={onPressClearQuery}
-          onPressCancelSearch={onPressCancelSearch}
-          onSubmitQuery={onSubmitQuery}
-          showMenu={false}
-        />
-        <FlatList
-          style={[!isTabletOrDesktop && s.flex1]}
-          data={feeds.feeds}
-          keyExtractor={item => item.data.uri}
-          contentContainerStyle={styles.contentContainer}
-          refreshControl={
-            <RefreshControl
-              refreshing={feeds.isRefreshing}
-              onRefresh={onRefresh}
-              tintColor={pal.colors.text}
-              titleColor={pal.colors.text}
-            />
-          }
-          renderItem={renderItem}
-          initialNumToRender={10}
-          ListEmptyComponent={renderListEmptyComponent}
-          onEndReached={() => feeds.loadMore()}
-          extraData={feeds.isLoading}
-        />
-      </CenteredView>
-    )
-  }),
-)
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-  },
-  contentContainer: {
-    paddingBottom: 100,
-  },
-  containerDesktop: {
-    borderLeftWidth: 1,
-    borderRightWidth: 1,
-  },
-  empty: {
-    paddingHorizontal: 16,
-    paddingTop: 10,
-  },
-})
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
index 97c6e8672..d2c4a6d2d 100644
--- a/src/view/screens/Feeds.tsx
+++ b/src/view/screens/Feeds.tsx
@@ -1,90 +1,72 @@
 import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {useFocusEffect} from '@react-navigation/native'
-import isEqual from 'lodash.isequal'
+import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
+import {AtUri} from '@atproto/api'
 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 {usePalette} from 'lib/hooks/usePalette'
-import {useTimer} from 'lib/hooks/useTimer'
 import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
 import {ComposeIcon2, CogIcon} from 'lib/icons'
 import {s} from 'lib/styles'
-
-const LOAD_NEW_PROMPT_TIME = 60e3 // 60 seconds
-const MOBILE_HEADER_OFFSET = 40
+import {SearchInput} from 'view/com/util/forms/SearchInput'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
+import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
+import debounce from 'lodash.debounce'
+import {Text} from 'view/com/util/text/Text'
+import {MyFeedsUIModel, MyFeedsItem} from 'state/models/ui/my-feeds'
+import {FlatList} from 'view/com/util/Views'
+import {useFocusEffect} from '@react-navigation/native'
+import {CustomFeed} from 'view/com/feeds/CustomFeed'
 
 type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
 export const FeedsScreen = withAuthRequired(
   observer<Props>(function FeedsScreenImpl({}: Props) {
     const pal = usePalette('default')
     const store = useStores()
-    const {isMobile} = useWebMediaQueries()
-    const flatListRef = React.useRef<FlatList>(null)
-    const multifeed = React.useMemo<PostsMultiFeedModel>(
-      () => new PostsMultiFeedModel(store),
-      [store],
+    const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
+    const myFeeds = React.useMemo(() => new MyFeedsUIModel(store), [store])
+    const [query, setQuery] = React.useState<string>('')
+    const debouncedSearchFeeds = React.useMemo(
+      () => debounce(q => myFeeds.discovery.search(q), 500), // debounce for 500ms
+      [myFeeds],
     )
-    const [onMainScroll, isScrolledDown, resetMainScroll] =
-      useOnMainScroll(store)
-    const [loadPromptVisible, setLoadPromptVisible] = React.useState(false)
-    const [resetPromptTimer] = useTimer(LOAD_NEW_PROMPT_TIME, () => {
-      setLoadPromptVisible(true)
-    })
-
-    const onSoftReset = React.useCallback(() => {
-      flatListRef.current?.scrollToOffset({offset: 0})
-      multifeed.loadLatest()
-      resetPromptTimer()
-      setLoadPromptVisible(false)
-      resetMainScroll()
-    }, [
-      flatListRef,
-      resetMainScroll,
-      multifeed,
-      resetPromptTimer,
-      setLoadPromptVisible,
-    ])
 
     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]),
+        myFeeds.setup()
+      }, [store.shell, myFeeds]),
     )
 
-    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 onChangeQuery = React.useCallback(
+      (text: string) => {
+        setQuery(text)
+        if (text.length > 1) {
+          debouncedSearchFeeds(text)
+        } else {
+          myFeeds.discovery.refresh()
+        }
+      },
+      [debouncedSearchFeeds, myFeeds.discovery],
+    )
+    const onPressCancelSearch = React.useCallback(() => {
+      setQuery('')
+      myFeeds.discovery.refresh()
+    }, [myFeeds])
+    const onSubmitQuery = React.useCallback(() => {
+      debouncedSearchFeeds(query)
+      debouncedSearchFeeds.flush()
+    }, [debouncedSearchFeeds, query])
 
     const renderHeaderBtn = React.useCallback(() => {
       return (
@@ -99,30 +81,150 @@ export const FeedsScreen = withAuthRequired(
       )
     }, [pal])
 
+    const onRefresh = React.useCallback(() => {
+      myFeeds.refresh()
+    }, [myFeeds])
+
+    const renderItem = React.useCallback(
+      ({item}: {item: MyFeedsItem}) => {
+        if (item.type === 'discover-feeds-loading') {
+          return <FeedFeedLoadingPlaceholder />
+        } else if (item.type === 'spinner') {
+          return (
+            <View style={s.p10}>
+              <ActivityIndicator />
+            </View>
+          )
+        } else if (item.type === 'error') {
+          return <ErrorMessage message={item.error} />
+        } else if (item.type === 'saved-feeds-header') {
+          if (!isMobile) {
+            return (
+              <View
+                style={[
+                  pal.view,
+                  styles.header,
+                  pal.border,
+                  {
+                    borderBottomWidth: 1,
+                  },
+                ]}>
+                <Text type="title-lg" style={[pal.text, s.bold]}>
+                  My Feeds
+                </Text>
+                <Link href="/settings/saved-feeds">
+                  <CogIcon strokeWidth={1.5} style={pal.icon} size={28} />
+                </Link>
+              </View>
+            )
+          }
+          return <View />
+        } else if (item.type === 'saved-feed') {
+          return (
+            <SavedFeed
+              uri={item.feed.uri}
+              avatar={item.feed.data.avatar}
+              displayName={item.feed.displayName}
+            />
+          )
+        } else if (item.type === 'discover-feeds-header') {
+          return (
+            <>
+              <View
+                style={[
+                  pal.view,
+                  styles.header,
+                  {
+                    marginTop: 16,
+                    paddingLeft: isMobile ? 12 : undefined,
+                    paddingRight: 10,
+                    paddingBottom: isMobile ? 6 : undefined,
+                  },
+                ]}>
+                <Text type="title-lg" style={[pal.text, s.bold]}>
+                  Discover new feeds
+                </Text>
+                {!isMobile && (
+                  <SearchInput
+                    query={query}
+                    onChangeQuery={onChangeQuery}
+                    onPressCancelSearch={onPressCancelSearch}
+                    onSubmitQuery={onSubmitQuery}
+                    style={{flex: 1, maxWidth: 250}}
+                  />
+                )}
+              </View>
+              {isMobile && (
+                <View style={{paddingHorizontal: 8, paddingBottom: 10}}>
+                  <SearchInput
+                    query={query}
+                    onChangeQuery={onChangeQuery}
+                    onPressCancelSearch={onPressCancelSearch}
+                    onSubmitQuery={onSubmitQuery}
+                  />
+                </View>
+              )}
+            </>
+          )
+        } else if (item.type === 'discover-feed') {
+          return (
+            <CustomFeed
+              item={item.feed}
+              showSaveBtn
+              showDescription
+              showLikes
+            />
+          )
+        } else if (item.type === 'discover-feeds-no-results') {
+          return (
+            <View
+              style={{
+                paddingHorizontal: 16,
+                paddingTop: 10,
+                paddingBottom: '150%',
+              }}>
+              <Text type="lg" style={pal.textLight}>
+                No results found for "{query}"
+              </Text>
+            </View>
+          )
+        }
+        return null
+      },
+      [isMobile, pal, query, onChangeQuery, onPressCancelSearch, onSubmitQuery],
+    )
+
     return (
       <View style={[pal.view, styles.container]}>
-        <MultiFeed
-          scrollElRef={flatListRef}
-          multifeed={multifeed}
-          onScroll={onMainScroll}
-          scrollEventThrottle={100}
-          headerOffset={isMobile ? MOBILE_HEADER_OFFSET : undefined}
-        />
         {isMobile && (
           <ViewHeader
-            title="My Feeds"
+            title="Feeds"
             canGoBack={false}
-            hideOnScroll
             renderButton={renderHeaderBtn}
+            showBorder
           />
         )}
-        {isScrolledDown || loadPromptVisible ? (
-          <LoadLatestBtn
-            onPress={onSoftReset}
-            label="Load latest posts"
-            showIndicator={loadPromptVisible}
-          />
-        ) : null}
+
+        <FlatList
+          style={[!isTabletOrDesktop && s.flex1, styles.list]}
+          data={myFeeds.items}
+          keyExtractor={item => item._reactKey}
+          contentContainerStyle={styles.contentContainer}
+          refreshControl={
+            <RefreshControl
+              refreshing={myFeeds.isRefreshing}
+              onRefresh={onRefresh}
+              tintColor={pal.colors.text}
+              titleColor={pal.colors.text}
+            />
+          }
+          renderItem={renderItem}
+          initialNumToRender={10}
+          onEndReached={() => myFeeds.loadMore()}
+          extraData={myFeeds.isLoading}
+          // @ts-ignore our .web version only -prf
+          desktopFixedHeight
+        />
         <FAB
           testID="composeFAB"
           onPress={onPressCompose}
@@ -136,8 +238,76 @@ export const FeedsScreen = withAuthRequired(
   }),
 )
 
+function SavedFeed({
+  uri,
+  avatar,
+  displayName,
+}: {
+  uri: string
+  avatar: string | undefined
+  displayName: string
+}) {
+  const pal = usePalette('default')
+  const urip = new AtUri(uri)
+  const href = `/profile/${urip.hostname}/feed/${urip.rkey}`
+  const {isMobile} = useWebMediaQueries()
+  return (
+    <Link
+      testID={`saved-feed-${displayName}`}
+      href={href}
+      style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]}
+      hoverStyle={pal.viewLight}
+      accessibilityLabel={displayName}
+      accessibilityHint=""
+      asAnchor
+      anchorNoUnderline>
+      <UserAvatar type="algo" size={28} avatar={avatar} />
+      <Text
+        type={isMobile ? 'lg' : 'lg-medium'}
+        style={[pal.text, s.flex1]}
+        numberOfLines={1}>
+        {displayName}
+      </Text>
+      {isMobile && (
+        <FontAwesomeIcon
+          icon="chevron-right"
+          size={14}
+          style={pal.textLight as FontAwesomeIconStyle}
+        />
+      )}
+    </Link>
+  )
+}
+
 const styles = StyleSheet.create({
   container: {
     flex: 1,
   },
+  list: {
+    height: '100%',
+  },
+  contentContainer: {
+    paddingBottom: 100,
+  },
+
+  header: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'space-between',
+    gap: 16,
+    paddingHorizontal: 16,
+    paddingVertical: 12,
+  },
+
+  savedFeed: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 16,
+    paddingVertical: 14,
+    gap: 12,
+    borderBottomWidth: 1,
+  },
+  savedFeedMobile: {
+    paddingVertical: 10,
+  },
 })
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 33cc2e110..60cda31db 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,6 +1,8 @@
 import React from 'react'
 import {FlatList, View} from 'react-native'
 import {useFocusEffect, useIsFocused} from '@react-navigation/native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
 import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api'
 import {observer} from 'mobx-react-lite'
 import useAppState from 'react-native-appstate-hook'
@@ -8,6 +10,7 @@ import isEqual from 'lodash.isequal'
 import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
 import {PostsFeedModel} from 'state/models/feeds/posts'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {TextLink} from 'view/com/util/Link'
 import {Feed} from '../com/posts/Feed'
 import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
 import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
@@ -16,14 +19,16 @@ import {FeedsTabBar} from '../com/pager/FeedsTabBar'
 import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
 import {FAB} from '../com/util/fab/FAB'
 import {useStores} from 'state/index'
-import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {s, colors} from 'lib/styles'
 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {ComposeIcon2} from 'lib/icons'
 
 const HEADER_OFFSET_MOBILE = 78
-const HEADER_OFFSET_DESKTOP = 50
+const HEADER_OFFSET_TABLET = 50
+const HEADER_OFFSET_DESKTOP = 0
 const POLL_FREQ = 30e3 // 30sec
 
 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
@@ -154,17 +159,23 @@ const FeedPage = observer(function FeedPageImpl({
   renderEmptyState?: () => JSX.Element
 }) {
   const store = useStores()
-  const {isMobile} = useWebMediaQueries()
+  const pal = usePalette('default')
+  const {isMobile, isTablet, isDesktop} = useWebMediaQueries()
   const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store)
   const {screen, track} = useAnalytics()
   const [headerOffset, setHeaderOffset] = React.useState(
-    isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP,
+    isMobile
+      ? HEADER_OFFSET_MOBILE
+      : isTablet
+      ? HEADER_OFFSET_TABLET
+      : HEADER_OFFSET_DESKTOP,
   )
   const scrollElRef = React.useRef<FlatList>(null)
   const {appState} = useAppState({
     onForeground: () => doPoll(true),
   })
   const isScreenFocused = useIsFocused()
+  const hasNew = feed.hasNewLatest && !feed.isRefreshing
 
   React.useEffect(() => {
     // called on first load
@@ -205,8 +216,14 @@ const FeedPage = observer(function FeedPageImpl({
 
   // listens for resize events
   React.useEffect(() => {
-    setHeaderOffset(isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP)
-  }, [isMobile])
+    setHeaderOffset(
+      isMobile
+        ? HEADER_OFFSET_MOBILE
+        : isTablet
+        ? HEADER_OFFSET_TABLET
+        : HEADER_OFFSET_DESKTOP,
+    )
+  }, [isMobile, isTablet])
 
   // fires when page within screen is activated/deactivated
   // - check for latest
@@ -222,9 +239,6 @@ const FeedPage = observer(function FeedPageImpl({
     screen('Feed')
     store.log.debug('HomeScreen: Updating feed')
     feed.checkForLatest()
-    if (feed.hasContent) {
-      feed.update()
-    }
 
     return () => {
       clearInterval(pollInterval)
@@ -247,7 +261,59 @@ const FeedPage = observer(function FeedPageImpl({
     feed.refresh()
   }, [feed, scrollToTop])
 
-  const hasNew = feed.hasNewLatest && !feed.isRefreshing
+  const ListHeaderComponent = React.useCallback(() => {
+    if (isDesktop) {
+      return (
+        <View
+          style={[
+            pal.view,
+            {
+              flexDirection: 'row',
+              alignItems: 'center',
+              justifyContent: 'space-between',
+              paddingHorizontal: 18,
+              paddingVertical: 12,
+            },
+          ]}>
+          <TextLink
+            type="title-lg"
+            href="/"
+            style={[pal.text, {fontWeight: 'bold'}]}
+            text={
+              <>
+                {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
+                {hasNew && (
+                  <View
+                    style={{
+                      top: -8,
+                      backgroundColor: colors.blue3,
+                      width: 8,
+                      height: 8,
+                      borderRadius: 4,
+                    }}
+                  />
+                )}
+              </>
+            }
+            onPress={() => store.emitScreenSoftReset()}
+          />
+          <TextLink
+            type="title-lg"
+            href="/settings/home-feed"
+            style={{fontWeight: 'bold'}}
+            text={
+              <FontAwesomeIcon
+                icon="sliders"
+                style={pal.textLight as FontAwesomeIconStyle}
+              />
+            }
+          />
+        </View>
+      )
+    }
+    return <></>
+  }, [isDesktop, pal, store, hasNew])
+
   return (
     <View testID={testID} style={s.h100pct}>
       <Feed
@@ -259,6 +325,7 @@ const FeedPage = observer(function FeedPageImpl({
         onScroll={onMainScroll}
         scrollEventThrottle={100}
         renderEmptyState={renderEmptyState}
+        ListHeaderComponent={ListHeaderComponent}
         headerOffset={headerOffset}
       />
       {(isScrolledDown || hasNew) && (
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index 3c257fac8..243cc9596 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -9,12 +9,15 @@ import {
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {Feed} from '../com/notifications/Feed'
+import {TextLink} from 'view/com/util/Link'
 import {InvitedUsers} from '../com/notifications/InvitedUsers'
 import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
 import {useStores} from 'state/index'
 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
 import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect'
-import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {s, colors} from 'lib/styles'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {isWeb} from 'platform/detection'
 
@@ -29,6 +32,12 @@ export const NotificationsScreen = withAuthRequired(
       useOnMainScroll(store)
     const scrollElRef = React.useRef<FlatList>(null)
     const {screen} = useAnalytics()
+    const pal = usePalette('default')
+    const {isDesktop} = useWebMediaQueries()
+
+    const hasNew =
+      store.me.notifications.hasNewLatest &&
+      !store.me.notifications.isRefreshing
 
     // event handlers
     // =
@@ -88,9 +97,48 @@ export const NotificationsScreen = withAuthRequired(
       ),
     )
 
-    const hasNew =
-      store.me.notifications.hasNewLatest &&
-      !store.me.notifications.isRefreshing
+    const ListHeaderComponent = React.useCallback(() => {
+      if (isDesktop) {
+        return (
+          <View
+            style={[
+              pal.view,
+              {
+                flexDirection: 'row',
+                alignItems: 'center',
+                justifyContent: 'space-between',
+                paddingHorizontal: 18,
+                paddingVertical: 12,
+              },
+            ]}>
+            <TextLink
+              type="title-lg"
+              href="/notifications"
+              style={[pal.text, {fontWeight: 'bold'}]}
+              text={
+                <>
+                  Notifications{' '}
+                  {hasNew && (
+                    <View
+                      style={{
+                        top: -8,
+                        backgroundColor: colors.blue3,
+                        width: 8,
+                        height: 8,
+                        borderRadius: 4,
+                      }}
+                    />
+                  )}
+                </>
+              }
+              onPress={() => store.emitScreenSoftReset()}
+            />
+          </View>
+        )
+      }
+      return <></>
+    }, [isDesktop, pal, store, hasNew])
+
     return (
       <View testID="notificationsScreen" style={s.hContentRegion}>
         <ViewHeader title="Notifications" canGoBack={false} />
@@ -100,6 +148,7 @@ export const NotificationsScreen = withAuthRequired(
           onPressTryAgain={onPressTryAgain}
           onScroll={onMainScroll}
           scrollElRef={scrollElRef}
+          ListHeaderComponent={ListHeaderComponent}
         />
         {(isScrolledDown || hasNew) && (
           <LoadLatestBtn
diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx
index 49c13bfa3..81bdfc95e 100644
--- a/src/view/screens/PreferencesHomeFeed.tsx
+++ b/src/view/screens/PreferencesHomeFeed.tsx
@@ -19,14 +19,7 @@ function RepliesThresholdInput({enabled}: {enabled: boolean}) {
   const [value, setValue] = useState(store.preferences.homeFeedRepliesThreshold)
 
   return (
-    <View style={[s.mt10, !enabled && styles.dimmed]}>
-      <Text type="xs" style={pal.text}>
-        {value === 0
-          ? `Show all replies`
-          : `Show replies with at least ${value} ${
-              value > 1 ? `likes` : `like`
-            }`}
-      </Text>
+    <View style={[!enabled && styles.dimmed]}>
       <Slider
         value={value}
         onValueChange={(v: number | number[]) => {
@@ -40,6 +33,13 @@ function RepliesThresholdInput({enabled}: {enabled: boolean}) {
         disabled={!enabled}
         thumbTintColor={colors.blue3}
       />
+      <Text type="xs" style={pal.text}>
+        {value === 0
+          ? `Show all replies`
+          : `Show replies with at least ${value} ${
+              value > 1 ? `likes` : `like`
+            }`}
+      </Text>
     </View>
   )
 }
@@ -79,8 +79,7 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
               Show Replies
             </Text>
             <Text style={[pal.text, s.pb10]}>
-              Adjust the number of likes a reply must have to be shown in your
-              feed.
+              Set this setting to "No" to hide all replies from your feed.
             </Text>
             <ToggleButton
               type="default-light"
@@ -88,7 +87,36 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
               isSelected={store.preferences.homeFeedRepliesEnabled}
               onPress={store.preferences.toggleHomeFeedRepliesEnabled}
             />
-
+          </View>
+          <View
+            style={[
+              pal.viewLight,
+              styles.card,
+              !store.preferences.homeFeedRepliesEnabled && styles.dimmed,
+            ]}>
+            <Text type="title-sm" style={[pal.text, s.pb5]}>
+              Reply Filters
+            </Text>
+            <Text style={[pal.text, s.pb10]}>
+              Enable this setting to only see replies between people you follow.
+            </Text>
+            <ToggleButton
+              type="default-light"
+              label="Followed users only"
+              isSelected={
+                store.preferences.homeFeedRepliesByFollowedOnlyEnabled
+              }
+              onPress={
+                store.preferences.homeFeedRepliesEnabled
+                  ? store.preferences.toggleHomeFeedRepliesByFollowedOnlyEnabled
+                  : undefined
+              }
+              style={[s.mb10]}
+            />
+            <Text style={[pal.text]}>
+              Adjust the number of likes a reply must have to be shown in your
+              feed.
+            </Text>
             <RepliesThresholdInput
               enabled={store.preferences.homeFeedRepliesEnabled}
             />
@@ -124,6 +152,22 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({
               onPress={store.preferences.toggleHomeFeedQuotePostsEnabled}
             />
           </View>
+
+          <View style={[pal.viewLight, styles.card]}>
+            <Text type="title-sm" style={[pal.text, s.pb5]}>
+              Show Posts from My Feeds (Experimental)
+            </Text>
+            <Text style={[pal.text, s.pb10]}>
+              Set this setting to "Yes" to show samples of your saved feeds in
+              your following feed.
+            </Text>
+            <ToggleButton
+              type="default-light"
+              label={store.preferences.homeFeedMergeFeedEnabled ? 'Yes' : 'No'}
+              isSelected={store.preferences.homeFeedMergeFeedEnabled}
+              onPress={store.preferences.toggleHomeFeedMergeFeedEnabled}
+            />
+          </View>
         </View>
       </ScrollView>
 
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 69b5ceee6..241bae1ed 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -69,9 +69,7 @@ export const ProfileScreen = withAuthRequired(
         let aborted = false
         store.shell.setMinimalShellMode(false)
         const feedCleanup = uiState.feed.registerListeners()
-        if (hasSetup) {
-          uiState.update()
-        } else {
+        if (!hasSetup) {
           uiState.setup().then(() => {
             if (aborted) {
               return
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index d5c02ba63..5253c5bd6 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -70,7 +70,7 @@ export const SavedFeeds = withAuthRequired(
       return (
         <>
           <View style={[styles.footerLinks, pal.border]}>
-            <Link style={styles.footerLink} href="/search/feeds">
+            <Link style={styles.footerLink} href="/feeds">
               <FontAwesomeIcon
                 icon="search"
                 size={18}
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 8a543fa4c..761f50d0a 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -40,7 +40,7 @@ import {AccountData} from 'state/models/session'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {NavigationProp} from 'lib/routes/types'
 import {pluralize} from 'lib/strings/helpers'
-import {HandIcon} from 'lib/icons'
+import {HandIcon, HashtagIcon} from 'lib/icons'
 import {formatCount} from 'view/com/util/numeric/format'
 import Clipboard from '@react-native-clipboard/clipboard'
 import {reset as resetNavigation} from '../../Navigation'
@@ -423,17 +423,14 @@ export const SettingsScreen = withAuthRequired(
           <TouchableOpacity
             testID="savedFeedsBtn"
             style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
-            accessibilityHint="Saved Feeds"
+            accessibilityHint="My Saved Feeds"
             accessibilityLabel="Opens screen with all saved feeds"
             onPress={onPressSavedFeeds}>
             <View style={[styles.iconContainer, pal.btn]}>
-              <FontAwesomeIcon
-                icon="satellite-dish"
-                style={pal.text as FontAwesomeIconStyle}
-              />
+              <HashtagIcon style={pal.text} size={18} strokeWidth={3} />
             </View>
             <Text type="lg" style={pal.text}>
-              Saved Feeds
+              My Saved Feeds
             </Text>
           </TouchableOpacity>
           <TouchableOpacity
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 3a4b8947a..174e4a806 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -28,8 +28,7 @@ import {
   MagnifyingGlassIcon2,
   MagnifyingGlassIcon2Solid,
   UserIconSolid,
-  SatelliteDishIcon,
-  SatelliteDishIconSolid,
+  HashtagIcon,
   HandIcon,
 } from 'lib/icons'
 import {UserAvatar} from 'view/com/util/UserAvatar'
@@ -258,21 +257,21 @@ export const DrawerContent = observer(function DrawerContentImpl() {
           <MenuItem
             icon={
               isAtFeeds ? (
-                <SatelliteDishIconSolid
-                  strokeWidth={1.5}
+                <HashtagIcon
+                  strokeWidth={3}
                   style={pal.text as FontAwesomeIconStyle}
                   size={24}
                 />
               ) : (
-                <SatelliteDishIcon
-                  strokeWidth={1.5}
+                <HashtagIcon
+                  strokeWidth={2}
                   style={pal.text as FontAwesomeIconStyle}
                   size={24}
                 />
               )
             }
-            label="My Feeds"
-            accessibilityLabel="My Feeds"
+            label="Feeds"
+            accessibilityLabel="Feeds"
             accessibilityHint=""
             onPress={onPressMyFeeds}
           />
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index 4a34371ea..8ba74da2e 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -18,8 +18,7 @@ import {
   HomeIconSolid,
   MagnifyingGlassIcon2,
   MagnifyingGlassIcon2Solid,
-  SatelliteDishIcon,
-  SatelliteDishIconSolid,
+  HashtagIcon,
   BellIcon,
   BellIconSolid,
 } from 'lib/icons'
@@ -134,16 +133,16 @@ export const BottomBar = observer(function BottomBarImpl({
         testID="bottomBarFeedsBtn"
         icon={
           isAtFeeds ? (
-            <SatelliteDishIconSolid
-              size={25}
-              style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
-              strokeWidth={1.8}
+            <HashtagIcon
+              size={24}
+              style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
+              strokeWidth={4}
             />
           ) : (
-            <SatelliteDishIcon
-              size={25}
-              style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
-              strokeWidth={1.8}
+            <HashtagIcon
+              size={24}
+              style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
+              strokeWidth={2.25}
             />
           )
         }
diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx
index f31ab44cf..ae9381440 100644
--- a/src/view/shell/bottom-bar/BottomBarStyles.tsx
+++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx
@@ -49,6 +49,9 @@ export const styles = StyleSheet.create({
   homeIcon: {
     top: 0,
   },
+  feedsIcon: {
+    top: -2,
+  },
   searchIcon: {
     top: -2,
   },
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index af70d3364..6448eea63 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -15,8 +15,7 @@ import {
   HomeIconSolid,
   MagnifyingGlassIcon2,
   MagnifyingGlassIcon2Solid,
-  SatelliteDishIcon,
-  SatelliteDishIconSolid,
+  HashtagIcon,
   UserIcon,
   UserIconSolid,
 } from 'lib/icons'
@@ -68,12 +67,11 @@ export const BottomBarWeb = observer(function BottomBarWebImpl() {
       </NavItem>
       <NavItem routeName="Feeds" href="/feeds">
         {({isActive}) => {
-          const Icon = isActive ? SatelliteDishIconSolid : SatelliteDishIcon
           return (
-            <Icon
-              size={25}
-              style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
-              strokeWidth={1.8}
+            <HashtagIcon
+              size={22}
+              style={[styles.ctrlIcon, pal.text, styles.feedsIcon]}
+              strokeWidth={isActive ? 4 : 2.5}
             />
           )
         }}
diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx
new file mode 100644
index 000000000..4da1401c3
--- /dev/null
+++ b/src/view/shell/desktop/Feeds.tsx
@@ -0,0 +1,92 @@
+import React from 'react'
+import {View, StyleSheet} from 'react-native'
+import {useNavigationState} from '@react-navigation/native'
+import {AtUri} from '@atproto/api'
+import {observer} from 'mobx-react-lite'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {TextLink} from 'view/com/util/Link'
+import {getCurrentRoute} from 'lib/routes/helpers'
+
+export const DesktopFeeds = observer(function DesktopFeeds() {
+  const store = useStores()
+  const pal = usePalette('default')
+
+  const route = useNavigationState(state => {
+    if (!state) {
+      return {name: 'Home'}
+    }
+    return getCurrentRoute(state)
+  })
+
+  return (
+    <View style={[styles.container, pal.view, pal.border]}>
+      <FeedItem href="/" title="Following" current={route.name === 'Home'} />
+      {store.me.savedFeeds.pinned.map(feed => {
+        try {
+          const {hostname, rkey} = new AtUri(feed.uri)
+          const href = `/profile/${hostname}/feed/${rkey}`
+          const params = route.params as Record<string, string>
+          return (
+            <FeedItem
+              key={feed.uri}
+              href={href}
+              title={feed.displayName}
+              current={
+                route.name === 'CustomFeed' &&
+                params.name === hostname &&
+                params.rkey === rkey
+              }
+            />
+          )
+        } catch {
+          return null
+        }
+      })}
+      <View style={{paddingTop: 8, paddingBottom: 6}}>
+        <TextLink
+          type="lg"
+          href="/feeds"
+          text="More feeds"
+          style={[pal.link]}
+        />
+      </View>
+    </View>
+  )
+})
+
+function FeedItem({
+  title,
+  href,
+  current,
+}: {
+  title: string
+  href: string
+  current: boolean
+}) {
+  const pal = usePalette('default')
+  return (
+    <View style={{paddingVertical: 6}}>
+      <TextLink
+        type="xl"
+        href={href}
+        text={title}
+        style={[
+          current ? pal.text : pal.textLight,
+          {letterSpacing: 0.15, fontWeight: current ? '500' : 'normal'},
+        ]}
+      />
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    position: 'relative',
+    width: 300,
+    paddingHorizontal: 12,
+    borderTopWidth: 1,
+    borderBottomWidth: 1,
+    paddingVertical: 18,
+  },
+})
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index 8c1a33245..907df8641 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -32,8 +32,7 @@ import {
   CogIconSolid,
   ComposeIcon2,
   HandIcon,
-  SatelliteDishIcon,
-  SatelliteDishIconSolid,
+  HashtagIcon,
 } from 'lib/icons'
 import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers'
 import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types'
@@ -272,20 +271,20 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
       <NavItem
         href="/feeds"
         icon={
-          <SatelliteDishIcon
-            strokeWidth={1.75}
+          <HashtagIcon
+            strokeWidth={2.25}
             style={pal.text as FontAwesomeIconStyle}
             size={isDesktop ? 24 : 28}
           />
         }
         iconFilled={
-          <SatelliteDishIconSolid
-            strokeWidth={1.75}
+          <HashtagIcon
+            strokeWidth={2.5}
             style={pal.text as FontAwesomeIconStyle}
             size={isDesktop ? 24 : 28}
           />
         }
-        label="My Feeds"
+        label="Feeds"
       />
       <NavItem
         href="/notifications"
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index e17fa6a84..12ca256d2 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -4,6 +4,7 @@ import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {usePalette} from 'lib/hooks/usePalette'
 import {DesktopSearch} from './Search'
+import {DesktopFeeds} from './Feeds'
 import {Text} from 'view/com/util/text/Text'
 import {TextLink} from 'view/com/util/Link'
 import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
@@ -26,6 +27,7 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() {
   return (
     <View style={[styles.rightNav, pal.view]}>
       {store.session.hasSession && <DesktopSearch />}
+      {store.session.hasSession && <DesktopFeeds />}
       <View style={styles.message}>
         {store.session.isSandbox ? (
           <View style={[palError.view, styles.messageLine, s.p10]}>
@@ -126,7 +128,7 @@ const styles = StyleSheet.create({
   },
 
   message: {
-    marginTop: 20,
+    paddingVertical: 18,
     paddingHorizontal: 10,
   },
   messageLine: {
@@ -134,7 +136,6 @@ const styles = StyleSheet.create({
   },
 
   inviteCodes: {
-    marginTop: 12,
     borderTopWidth: 1,
     paddingHorizontal: 16,
     paddingVertical: 12,
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index c7b322b58..dfd4f50bf 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -113,6 +113,7 @@ const styles = StyleSheet.create({
   container: {
     position: 'relative',
     width: 300,
+    paddingBottom: 18,
   },
   search: {
     paddingHorizontal: 16,
diff --git a/yarn.lock b/yarn.lock
index 41b423366..3ee7d4c0d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6418,6 +6418,13 @@
   dependencies:
     "@types/lodash" "*"
 
+"@types/lodash.random@^3.2.7":
+  version "3.2.7"
+  resolved "https://registry.yarnpkg.com/@types/lodash.random/-/lodash.random-3.2.7.tgz#3100a1b7956ce86ab5adcce2e7b305412b98e3bf"
+  integrity sha512-gFKkVgWYi1q7RFJ+QNTzaRprdhVIZLpZd6C3MTNehKcujMn9SyFUqf2fTBOmvIYXqNk0RpwfbdOwHf0GnEQB0g==
+  dependencies:
+    "@types/lodash" "*"
+
 "@types/lodash.samplesize@^4.2.7":
   version "4.2.7"
   resolved "https://registry.yarnpkg.com/@types/lodash.samplesize/-/lodash.samplesize-4.2.7.tgz#15784dd9e54aa1bf043552bdb533b83fcf50b82f"
@@ -13886,6 +13893,11 @@ lodash.once@^4.0.0, lodash.once@^4.1.1:
   resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
   integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==
 
+lodash.random@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/lodash.random/-/lodash.random-3.2.0.tgz#96e24e763333199130d2c9e2fd57f91703cc262d"
+  integrity sha512-A6Vn7teN0+qSnhOsE8yx2bGowCS1G7D9e5abq8VhwOP98YHS/KrGMf43yYxA05lvcvloT+W9Z2ffkSajFTcPUA==
+
 lodash.samplesize@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/lodash.samplesize/-/lodash.samplesize-4.2.0.tgz#460762fbb2b342290517499e90d51586db465ff9"
@@ -16855,10 +16867,10 @@ react-dom@^18.2.0:
     loose-envify "^1.1.0"
     scheduler "^0.23.0"
 
-react-error-overlay@^6.0.11:
-  version "6.0.11"
-  resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
-  integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
+react-error-overlay@6.0.9, react-error-overlay@^6.0.11:
+  version "6.0.9"
+  resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a"
+  integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew==
 
 react-freeze@^1.0.0:
   version "1.0.3"