about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-03-19 18:53:57 -0500
committerGitHub <noreply@github.com>2023-03-19 18:53:57 -0500
commit1de724b24b9607d4ee83dc0dbb92c13b2b77dcaf (patch)
treede1b244a976e55818f1181e6bf2b727237aff7c2 /src
parentc31ffdac1b970d8d51c538f931cc64a942670740 (diff)
downloadvoidsky-1de724b24b9607d4ee83dc0dbb92c13b2b77dcaf.tar.zst
Add custom feeds selector, rework search, simplify onboarding (#325)
* Get home screen's swipable pager working with the drawer

* Add tab bar to pager

* Implement popular & following views on home screen

* Visual tune-up

* Move the feed selector to the footer

* Fix to 'new posts' poll

* Add the view header as a feed item

* Use the native driver on the tabbar indicator to improve perf

* Reduce home polling to the currently active page; also reuse some code

* Add soft reset on tap selected in tab bar

* Remove explicit 'onboarding' flow

* Choose good stuff based on service

* Add foaf-based follow discovery

* Fall back to who to follow

* Fix backgrounds

* Switch to the off-spec goodstuff route

* 1.8

* Fix for dev & staging

* Swap the tab bar items and rename suggested to what's hot

* Go to whats-hot by default if you have no follows

* Implement pager and tabbar for desktop web

* Pin deps to make expo happy

* Add language filtering to goodstuff
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx10
-rw-r--r--src/lib/api/feed-manip.ts32
-rw-r--r--src/lib/hooks/usePalette.ts4
-rw-r--r--src/lib/styles.ts1
-rw-r--r--src/state/models/discovery/foafs.ts110
-rw-r--r--src/state/models/feed-view.ts85
-rw-r--r--src/state/models/me.ts1
-rw-r--r--src/state/models/my-follows.ts6
-rw-r--r--src/state/models/session.ts11
-rw-r--r--src/state/models/ui/shell.ts15
-rw-r--r--src/view/com/discover/SuggestedFollows.tsx160
-rw-r--r--src/view/com/posts/Feed.tsx74
-rw-r--r--src/view/com/posts/FollowingEmptyState.tsx81
-rw-r--r--src/view/com/profile/FollowButton.tsx6
-rw-r--r--src/view/com/profile/ProfileCard.tsx62
-rw-r--r--src/view/com/util/LoadingPlaceholder.tsx49
-rw-r--r--src/view/com/util/PostMeta.tsx11
-rw-r--r--src/view/com/util/TabBar.tsx162
-rw-r--r--src/view/com/util/WelcomeBanner.tsx101
-rw-r--r--src/view/com/util/pager/Pager.tsx87
-rw-r--r--src/view/com/util/pager/Pager.web.tsx69
-rw-r--r--src/view/screens/Home.tsx144
-rw-r--r--src/view/screens/Search.tsx93
-rw-r--r--src/view/screens/home/FeedsTabBar.tsx72
-rw-r--r--src/view/screens/home/FeedsTabBar.web.tsx22
-rw-r--r--src/view/shell/BottomBar.tsx22
-rw-r--r--src/view/shell/index.tsx6
27 files changed, 1133 insertions, 363 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index fcd6e787b..ebe6a7cd6 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -4,8 +4,10 @@ import {Linking} from 'react-native'
 import {RootSiblingParent} from 'react-native-root-siblings'
 import SplashScreen from 'react-native-splash-screen'
 import {SafeAreaProvider} from 'react-native-safe-area-context'
+import {GestureHandlerRootView} from 'react-native-gesture-handler'
 import {observer} from 'mobx-react-lite'
 import {ThemeProvider} from 'lib/ThemeContext'
+import {s} from 'lib/styles'
 import * as view from './view/index'
 import {RootStoreModel, setupState, RootStoreProvider} from './state'
 import {Shell} from './view/shell'
@@ -51,9 +53,11 @@ const App = observer(() => {
       <RootSiblingParent>
         <analytics.Provider>
           <RootStoreProvider value={rootStore}>
-            <SafeAreaProvider>
-              <Shell />
-            </SafeAreaProvider>
+            <GestureHandlerRootView style={s.h100pct}>
+              <SafeAreaProvider>
+                <Shell />
+              </SafeAreaProvider>
+            </GestureHandlerRootView>
           </RootStoreProvider>
         </analytics.Provider>
       </RootSiblingParent>
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
index 00938be93..67ca8f952 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -1,5 +1,7 @@
 import {AppBskyFeedFeedViewPost} from '@atproto/api'
+import lande from 'lande'
 type FeedViewPost = AppBskyFeedFeedViewPost.Main
+import {hasProp} from '@atproto/lexicon'
 
 export type FeedTunerFn = (
   tuner: FeedTuner,
@@ -140,7 +142,8 @@ export class FeedTuner {
       for (const item of slice.items) {
         this.seenUris.add(item.post.uri)
       }
-      slice.logSelf()
+      // DEBUG uncomment to get a quick view of the data
+      // slice.logSelf()
     }
 
     return slices
@@ -177,6 +180,33 @@ export class FeedTuner {
       }
     }
   }
+
+  static englishOnly(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
+    // TEMP
+    // remove slices with no english in them
+    // we very soon need to get the local user's language and filter
+    // according to their preferences, but for the moment
+    // we're just rolling with english
+    // -prf
+    for (let i = slices.length - 1; i >= 0; i--) {
+      let hasEnglish = false
+      for (const item of slices[i].items) {
+        if (
+          hasProp(item.post.record, 'text') &&
+          typeof item.post.record.text === 'string'
+        ) {
+          const res = lande(item.post.record.text)
+          if (res[0][0] === 'eng') {
+            hasEnglish = true
+            break
+          }
+        }
+      }
+      if (!hasEnglish) {
+        slices.splice(i, 1)
+      }
+    }
+  }
 }
 
 function getSelfReplyUri(item: FeedViewPost): string | undefined {
diff --git a/src/lib/hooks/usePalette.ts b/src/lib/hooks/usePalette.ts
index 5b9929c7d..7eeb74228 100644
--- a/src/lib/hooks/usePalette.ts
+++ b/src/lib/hooks/usePalette.ts
@@ -4,6 +4,7 @@ import {useTheme, PaletteColorName, PaletteColor} from '../ThemeContext'
 export interface UsePaletteValue {
   colors: PaletteColor
   view: ViewStyle
+  viewLight: ViewStyle
   btn: ViewStyle
   border: ViewStyle
   borderDark: ViewStyle
@@ -20,6 +21,9 @@ export function usePalette(color: PaletteColorName): UsePaletteValue {
     view: {
       backgroundColor: palette.background,
     },
+    viewLight: {
+      backgroundColor: palette.backgroundLight,
+    },
     btn: {
       backgroundColor: palette.backgroundLight,
     },
diff --git a/src/lib/styles.ts b/src/lib/styles.ts
index 5d7f7f82d..aa255b21f 100644
--- a/src/lib/styles.ts
+++ b/src/lib/styles.ts
@@ -70,6 +70,7 @@ export const s = StyleSheet.create({
   borderRight1: {borderRightWidth: 1},
   borderBottom1: {borderBottomWidth: 1},
   borderLeft1: {borderLeftWidth: 1},
+  hidden: {display: 'none'},
 
   // font weights
   fw600: {fontWeight: '600'},
diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts
new file mode 100644
index 000000000..241338a16
--- /dev/null
+++ b/src/state/models/discovery/foafs.ts
@@ -0,0 +1,110 @@
+import {AppBskyActorProfile, AppBskyActorRef} from '@atproto/api'
+import {makeAutoObservable, runInAction} from 'mobx'
+import sampleSize from 'lodash.samplesize'
+import {bundleAsync} from 'lib/async/bundle'
+import {RootStoreModel} from '../root-store'
+
+export type RefWithInfoAndFollowers = AppBskyActorRef.WithInfo & {
+  followers: AppBskyActorProfile.View[]
+}
+
+export type ProfileViewFollows = AppBskyActorProfile.View & {
+  follows: AppBskyActorRef.WithInfo[]
+}
+
+export class FoafsModel {
+  isLoading = false
+  hasData = false
+  sources: string[] = []
+  foafs: Map<string, ProfileViewFollows> = new Map()
+  popular: RefWithInfoAndFollowers[] = []
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(this)
+  }
+
+  get hasContent() {
+    if (this.popular.length > 0) {
+      return true
+    }
+    for (const foaf of this.foafs.values()) {
+      if (foaf.follows.length) {
+        return true
+      }
+    }
+    return false
+  }
+
+  fetch = bundleAsync(async () => {
+    try {
+      this.isLoading = true
+      await this.rootStore.me.follows.fetchIfNeeded()
+      // grab 10 of the users followed by the user
+      this.sources = sampleSize(
+        Object.keys(this.rootStore.me.follows.followDidToRecordMap),
+        10,
+      )
+      if (this.sources.length === 0) {
+        return
+      }
+      this.foafs.clear()
+      this.popular.length = 0
+
+      // fetch their profiles
+      const profiles = await this.rootStore.api.app.bsky.actor.getProfiles({
+        actors: this.sources,
+      })
+
+      // fetch their follows
+      const results = await Promise.allSettled(
+        this.sources.map(source =>
+          this.rootStore.api.app.bsky.graph.getFollows({user: source}),
+        ),
+      )
+
+      // store the follows and construct a "most followed" set
+      const popular: RefWithInfoAndFollowers[] = []
+      for (let i = 0; i < results.length; i++) {
+        const res = results[i]
+        const profile = profiles.data.profiles[i]
+        const source = this.sources[i]
+        if (res.status === 'fulfilled' && profile) {
+          // filter out users already followed by the user or that *is* the user
+          res.value.data.follows = res.value.data.follows.filter(follow => {
+            return (
+              follow.did !== this.rootStore.me.did &&
+              !this.rootStore.me.follows.isFollowing(follow.did)
+            )
+          })
+
+          runInAction(() => {
+            this.foafs.set(source, {
+              ...profile,
+              follows: res.value.data.follows,
+            })
+          })
+          for (const follow of res.value.data.follows) {
+            let item = popular.find(p => p.did === follow.did)
+            if (!item) {
+              item = {...follow, followers: []}
+              popular.push(item)
+            }
+            item.followers.push(profile)
+          }
+        }
+      }
+
+      popular.sort((a, b) => b.followers.length - a.followers.length)
+      runInAction(() => {
+        this.popular = popular.filter(p => p.followers.length > 1).slice(0, 20)
+      })
+      this.hasData = true
+    } catch (e) {
+      console.error('Failed to fetch FOAFs', e)
+    } finally {
+      runInAction(() => {
+        this.isLoading = false
+      })
+    }
+  })
+}
diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts
index 42b753b24..c412065dd 100644
--- a/src/state/models/feed-view.ts
+++ b/src/state/models/feed-view.ts
@@ -257,7 +257,7 @@ export class FeedModel {
 
   constructor(
     public rootStore: RootStoreModel,
-    public feedType: 'home' | 'author' | 'suggested',
+    public feedType: 'home' | 'author' | 'suggested' | 'goodstuff',
     params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams,
   ) {
     makeAutoObservable(
@@ -336,6 +336,20 @@ export class FeedModel {
     return this.setup()
   }
 
+  private get feedTuners() {
+    if (this.feedType === 'goodstuff') {
+      return [
+        FeedTuner.dedupReposts,
+        FeedTuner.likedRepliesOnly,
+        FeedTuner.englishOnly,
+      ]
+    }
+    if (this.feedType === 'home') {
+      return [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly]
+    }
+    return []
+  }
+
   /**
    * Load for first render
    */
@@ -399,6 +413,7 @@ export class FeedModel {
           params: this.params,
           e,
         })
+        this.hasMore = false
       }
     } finally {
       this.lock.release()
@@ -476,7 +491,8 @@ export class FeedModel {
     }
     const res = await this._getFeed({limit: 1})
     const currentLatestUri = this.pollCursor
-    const item = res.data.feed[0]
+    const slices = this.tuner.tune(res.data.feed, this.feedTuners)
+    const item = slices[0]?.rootItem
     if (!item) {
       return
     }
@@ -541,12 +557,7 @@ export class FeedModel {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
 
-    const slices = this.tuner.tune(
-      res.data.feed,
-      this.feedType === 'home'
-        ? [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly]
-        : [],
-    )
+    const slices = this.tuner.tune(res.data.feed, this.feedTuners)
 
     const toAppend: FeedSliceModel[] = []
     for (const slice of slices) {
@@ -571,12 +582,7 @@ export class FeedModel {
   ) {
     this.pollCursor = res.data.feed[0]?.post.uri
 
-    const slices = this.tuner.tune(
-      res.data.feed,
-      this.feedType === 'home'
-        ? [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly]
-        : [],
-    )
+    const slices = this.tuner.tune(res.data.feed, this.feedTuners)
 
     const toPrepend: FeedSliceModel[] = []
     for (const slice of slices) {
@@ -634,6 +640,15 @@ export class FeedModel {
       return this.rootStore.api.app.bsky.feed.getTimeline(
         params as GetTimeline.QueryParams,
       )
+    } else if (this.feedType === 'goodstuff') {
+      const res = await getGoodStuff(
+        this.rootStore.session.currentSession?.accessJwt || '',
+        params as GetTimeline.QueryParams,
+      )
+      res.data.feed = (res.data.feed || []).filter(
+        item => !item.post.author.viewer?.muted,
+      )
+      return res
     } else {
       return this.rootStore.api.app.bsky.feed.getAuthorFeed(
         params as GetAuthorFeed.QueryParams,
@@ -641,3 +656,45 @@ export class FeedModel {
     }
   }
 }
+
+// HACK
+// temporary off-spec route to get the good stuff
+// -prf
+async function getGoodStuff(
+  accessJwt: string,
+  params: GetTimeline.QueryParams,
+): Promise<GetTimeline.Response> {
+  const controller = new AbortController()
+  const to = setTimeout(() => controller.abort(), 15e3)
+
+  const uri = new URL('https://bsky.social/xrpc/app.bsky.unspecced.getPopular')
+  let k: keyof GetTimeline.QueryParams
+  for (k in params) {
+    if (typeof params[k] !== 'undefined') {
+      uri.searchParams.set(k, String(params[k]))
+    }
+  }
+
+  const res = await fetch(String(uri), {
+    method: 'get',
+    headers: {
+      accept: 'application/json',
+      authorization: `Bearer ${accessJwt}`,
+    },
+    signal: controller.signal,
+  })
+
+  const resHeaders: Record<string, string> = {}
+  res.headers.forEach((value: string, key: string) => {
+    resHeaders[key] = value
+  })
+  let resBody = await res.json()
+
+  clearTimeout(to)
+
+  return {
+    success: res.status === 200,
+    headers: resHeaders,
+    data: resBody,
+  }
+}
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index 077c65595..192e8f19f 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -33,6 +33,7 @@ export class MeModel {
   clear() {
     this.mainFeed.clear()
     this.notifications.clear()
+    this.follows.clear()
     this.did = ''
     this.handle = ''
     this.displayName = ''
diff --git a/src/state/models/my-follows.ts b/src/state/models/my-follows.ts
index 732c2fe73..bf1bf9600 100644
--- a/src/state/models/my-follows.ts
+++ b/src/state/models/my-follows.ts
@@ -35,6 +35,12 @@ export class MyFollowsModel {
   // public api
   // =
 
+  clear() {
+    this.followDidToRecordMap = {}
+    this.lastSync = 0
+    this.myDid = undefined
+  }
+
   fetchIfNeeded = bundleAsync(async () => {
     if (
       this.myDid !== this.rootStore.me.did ||
diff --git a/src/state/models/session.ts b/src/state/models/session.ts
index 306c265d8..e131b2b2c 100644
--- a/src/state/models/session.ts
+++ b/src/state/models/session.ts
@@ -154,13 +154,13 @@ export class SessionModel {
   /**
    * Sets the active session
    */
-  setActiveSession(agent: AtpAgent, did: string) {
+  async setActiveSession(agent: AtpAgent, did: string) {
     this._log('SessionModel:setActiveSession')
     this.data = {
       service: agent.service.toString(),
       did,
     }
-    this.rootStore.handleSessionChange(agent)
+    await this.rootStore.handleSessionChange(agent)
   }
 
   /**
@@ -304,7 +304,7 @@ export class SessionModel {
       return false
     }
 
-    this.setActiveSession(agent, account.did)
+    await this.setActiveSession(agent, account.did)
     return true
   }
 
@@ -337,7 +337,7 @@ export class SessionModel {
       },
     )
 
-    this.setActiveSession(agent, did)
+    await this.setActiveSession(agent, did)
     this._log('SessionModel:login succeeded')
   }
 
@@ -376,8 +376,7 @@ export class SessionModel {
       },
     )
 
-    this.setActiveSession(agent, did)
-    this.rootStore.shell.setOnboarding(true)
+    await this.setActiveSession(agent, did)
     this._log('SessionModel:createAccount succeeded')
   }
 
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index d6fefb850..fec1e2899 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -122,13 +122,13 @@ export class ShellUiModel {
   darkMode = false
   minimalShellMode = false
   isDrawerOpen = false
+  isDrawerSwipeDisabled = false
   isModalActive = false
   activeModals: Modal[] = []
   isLightboxActive = false
   activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined
   isComposerActive = false
   composerOpts: ComposerOpts | undefined
-  isOnboarding = false
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(this, {
@@ -168,6 +168,10 @@ export class ShellUiModel {
     this.isDrawerOpen = false
   }
 
+  setIsDrawerSwipeDisabled(v: boolean) {
+    this.isDrawerSwipeDisabled = v
+  }
+
   openModal(modal: Modal) {
     this.rootStore.emitNavigation()
     this.isModalActive = true
@@ -200,13 +204,4 @@ export class ShellUiModel {
     this.isComposerActive = false
     this.composerOpts = undefined
   }
-
-  setOnboarding(v: boolean) {
-    this.isOnboarding = v
-    if (this.isOnboarding) {
-      this.rootStore.me.mainFeed.switchFeedType('suggested')
-    } else {
-      this.rootStore.me.mainFeed.switchFeedType('home')
-    }
-  }
 }
diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx
index 1e40956ce..7a64a15f6 100644
--- a/src/view/com/discover/SuggestedFollows.tsx
+++ b/src/view/com/discover/SuggestedFollows.tsx
@@ -1,116 +1,68 @@
 import React from 'react'
-import {ActivityIndicator, StyleSheet, View} from 'react-native'
-import {CenteredView, FlatList} from '../util/Views'
-import {observer} from 'mobx-react-lite'
-import {ErrorScreen} from '../util/error/ErrorScreen'
+import {StyleSheet, View} from 'react-native'
+import {AppBskyActorRef, AppBskyActorProfile} from '@atproto/api'
+import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs'
 import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
-import {useStores} from 'state/index'
-import {
-  SuggestedActorsViewModel,
-  SuggestedActor,
-} from 'state/models/suggested-actors-view'
-import {s} from 'lib/styles'
+import {Text} from '../util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 
-export const SuggestedFollows = observer(
-  ({onNoSuggestions}: {onNoSuggestions?: () => void}) => {
-    const pal = usePalette('default')
-    const store = useStores()
-
-    const view = React.useMemo<SuggestedActorsViewModel>(
-      () => new SuggestedActorsViewModel(store),
-      [store],
-    )
-
-    React.useEffect(() => {
-      view
-        .loadMore()
-        .catch((err: any) =>
-          store.log.error('Failed to fetch suggestions', err),
-        )
-    }, [view, store.log])
-
-    React.useEffect(() => {
-      if (!view.isLoading && !view.hasError && !view.hasContent) {
-        onNoSuggestions?.()
-      }
-    }, [view, view.isLoading, view.hasError, view.hasContent, onNoSuggestions])
-
-    const onRefresh = () => {
-      view
-        .refresh()
-        .catch((err: any) =>
-          store.log.error('Failed to fetch suggestions', err),
-        )
-    }
-    const onEndReached = () => {
-      view
-        .loadMore()
-        .catch(err =>
-          view?.rootStore.log.error('Failed to load more suggestions', err),
-        )
-    }
-
-    const renderItem = ({item}: {item: SuggestedActor}) => {
-      return (
-        <ProfileCardWithFollowBtn
-          key={item.did}
-          did={item.did}
-          declarationCid={item.declaration.cid}
-          handle={item.handle}
-          displayName={item.displayName}
-          avatar={item.avatar}
-          description={item.description}
-        />
-      )
-    }
-    return (
-      <View style={styles.container}>
-        {view.hasError ? (
-          <CenteredView>
-            <ErrorScreen
-              title="Failed to load suggestions"
-              message="There was an error while trying to load suggested follows."
-              details={view.error}
-              onPressTryAgain={onRefresh}
-            />
-          </CenteredView>
-        ) : view.isEmpty ? (
-          <View />
-        ) : (
-          <View style={[styles.suggestionsContainer, pal.view]}>
-            <FlatList
-              data={view.suggestions}
-              keyExtractor={item => item.did}
-              refreshing={view.isRefreshing}
-              onRefresh={onRefresh}
-              onEndReached={onEndReached}
-              renderItem={renderItem}
-              initialNumToRender={15}
-              ListFooterComponent={() => (
-                <View style={styles.footer}>
-                  {view.isLoading && <ActivityIndicator />}
-                </View>
-              )}
-              contentContainerStyle={s.contentContainer}
-            />
-          </View>
-        )}
-      </View>
-    )
-  },
-)
+export const SuggestedFollows = ({
+  title,
+  suggestions,
+}: {
+  title: string
+  suggestions: (AppBskyActorRef.WithInfo | RefWithInfoAndFollowers)[]
+}) => {
+  const pal = usePalette('default')
+  return (
+    <View style={[styles.container, pal.view]}>
+      <Text type="title" style={[styles.heading, pal.text]}>
+        {title}
+      </Text>
+      {suggestions.map(item => (
+        <View key={item.did} style={[styles.card, pal.view, pal.border]}>
+          <ProfileCardWithFollowBtn
+            key={item.did}
+            did={item.did}
+            declarationCid={item.declaration.cid}
+            handle={item.handle}
+            displayName={item.displayName}
+            avatar={item.avatar}
+            noBg
+            noBorder
+            description=""
+            followers={
+              item.followers
+                ? (item.followers as AppBskyActorProfile.View[])
+                : undefined
+            }
+          />
+        </View>
+      ))}
+    </View>
+  )
+}
 
 const styles = StyleSheet.create({
   container: {
-    height: '100%',
+    paddingVertical: 10,
+    paddingHorizontal: 4,
+  },
+
+  heading: {
+    fontWeight: 'bold',
+    paddingHorizontal: 4,
+    paddingBottom: 8,
   },
 
-  suggestionsContainer: {
-    height: '100%',
+  card: {
+    borderRadius: 12,
+    marginBottom: 2,
+    borderWidth: 1,
   },
-  footer: {
-    height: 200,
-    paddingTop: 20,
+
+  loadMore: {
+    paddingLeft: 16,
+    paddingVertical: 12,
   },
 })
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 1edcd55d9..c910b70e7 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -7,23 +7,17 @@ import {
   StyleSheet,
   ViewStyle,
 } from 'react-native'
-import {useNavigation} from '@react-navigation/native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
 import {CenteredView, FlatList} from '../util/Views'
 import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
-import {Text} from '../util/text/Text'
+import {ViewHeader} from '../util/ViewHeader'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {Button} from '../util/forms/Button'
 import {FeedModel} from 'state/models/feed-view'
 import {FeedSlice} from './FeedSlice'
 import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
 import {s} from 'lib/styles'
 import {useAnalytics} from 'lib/analytics'
-import {usePalette} from 'lib/hooks/usePalette'
-import {MagnifyingGlassIcon} from 'lib/icons'
-import {NavigationProp} from 'lib/routes/types'
 
+const HEADER_ITEM = {_reactKey: '__header__'}
 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
 const ERROR_FEED_ITEM = {_reactKey: '__error__'}
 
@@ -34,6 +28,7 @@ export const Feed = observer(function Feed({
   scrollElRef,
   onPressTryAgain,
   onScroll,
+  renderEmptyState,
   testID,
   headerOffset = 0,
 }: {
@@ -43,17 +38,15 @@ export const Feed = observer(function Feed({
   scrollElRef?: MutableRefObject<FlatList<any> | null>
   onPressTryAgain?: () => void
   onScroll?: OnScrollCb
+  renderEmptyState?: () => JSX.Element
   testID?: string
   headerOffset?: number
 }) {
-  const pal = usePalette('default')
-  const palInverted = usePalette('inverted')
   const {track} = useAnalytics()
   const [isRefreshing, setIsRefreshing] = React.useState(false)
-  const navigation = useNavigation<NavigationProp>()
 
   const data = React.useMemo(() => {
-    let feedItems: any[] = []
+    let feedItems: any[] = [HEADER_ITEM]
     if (feed.hasLoaded) {
       if (feed.hasError) {
         feedItems = feedItems.concat([ERROR_FEED_ITEM])
@@ -80,6 +73,7 @@ export const Feed = observer(function Feed({
     }
     setIsRefreshing(false)
   }, [feed, track, setIsRefreshing])
+
   const onEndReached = React.useCallback(async () => {
     track('Feed:onEndReached')
     try {
@@ -95,37 +89,10 @@ export const Feed = observer(function Feed({
   const renderItem = React.useCallback(
     ({item}: {item: any}) => {
       if (item === EMPTY_FEED_ITEM) {
-        return (
-          <View style={styles.emptyContainer}>
-            <View style={styles.emptyIconContainer}>
-              <MagnifyingGlassIcon
-                style={[styles.emptyIcon, pal.text]}
-                size={62}
-              />
-            </View>
-            <Text type="xl-medium" style={[s.textCenter, pal.text]}>
-              Your feed is empty! You should follow some accounts to fix this.
-            </Text>
-            <Button
-              type="inverted"
-              style={styles.emptyBtn}
-              onPress={
-                () =>
-                  navigation.navigate(
-                    'SearchTab',
-                  ) /* TODO make sure it goes to root of the tab */
-              }>
-              <Text type="lg-medium" style={palInverted.text}>
-                Find accounts
-              </Text>
-              <FontAwesomeIcon
-                icon="angle-right"
-                style={palInverted.text as FontAwesomeIconStyle}
-                size={14}
-              />
-            </Button>
-          </View>
-        )
+        if (renderEmptyState) {
+          return renderEmptyState()
+        }
+        return <View />
       } else if (item === ERROR_FEED_ITEM) {
         return (
           <ErrorMessage
@@ -133,10 +100,12 @@ export const Feed = observer(function Feed({
             onPressTryAgain={onPressTryAgain}
           />
         )
+      } else if (item === HEADER_ITEM) {
+        return <ViewHeader title="Bluesky" canGoBack={false} />
       }
       return <FeedSlice slice={item} showFollowBtn={showPostFollowBtn} />
     },
-    [feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, navigation],
+    [feed, onPressTryAgain, showPostFollowBtn, renderEmptyState],
   )
 
   const FeedFooter = React.useCallback(
@@ -183,21 +152,4 @@ export const Feed = observer(function Feed({
 
 const styles = StyleSheet.create({
   feedFooter: {paddingTop: 20},
-  emptyContainer: {
-    paddingVertical: 40,
-    paddingHorizontal: 30,
-  },
-  emptyIconContainer: {
-    marginBottom: 16,
-  },
-  emptyIcon: {
-    marginLeft: 'auto',
-    marginRight: 'auto',
-  },
-  emptyBtn: {
-    marginTop: 20,
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'space-between',
-  },
 })
diff --git a/src/view/com/posts/FollowingEmptyState.tsx b/src/view/com/posts/FollowingEmptyState.tsx
new file mode 100644
index 000000000..acd035f21
--- /dev/null
+++ b/src/view/com/posts/FollowingEmptyState.tsx
@@ -0,0 +1,81 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {useNavigation} from '@react-navigation/native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {Text} from '../util/text/Text'
+import {Button} from '../util/forms/Button'
+import {MagnifyingGlassIcon} from 'lib/icons'
+import {NavigationProp} from 'lib/routes/types'
+import {usePalette} from 'lib/hooks/usePalette'
+import {s} from 'lib/styles'
+
+export function FollowingEmptyState() {
+  const pal = usePalette('default')
+  const palInverted = usePalette('inverted')
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPressFindAccounts = React.useCallback(() => {
+    navigation.navigate('SearchTab')
+    navigation.popToTop()
+  }, [navigation])
+
+  return (
+    <View style={styles.emptyContainer}>
+      <View style={styles.emptyIconContainer}>
+        <MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} />
+      </View>
+      <Text type="xl-medium" style={[s.textCenter, pal.text]}>
+        Your following feed is empty! Find some accounts to follow to fix this.
+      </Text>
+      <Button
+        type="inverted"
+        style={styles.emptyBtn}
+        onPress={onPressFindAccounts}>
+        <Text type="lg-medium" style={palInverted.text}>
+          Find accounts to follow
+        </Text>
+        <FontAwesomeIcon
+          icon="angle-right"
+          style={palInverted.text as FontAwesomeIconStyle}
+          size={14}
+        />
+      </Button>
+    </View>
+  )
+}
+const styles = StyleSheet.create({
+  emptyContainer: {
+    // flex: 1,
+    height: '100%',
+    paddingVertical: 40,
+    paddingHorizontal: 30,
+  },
+  emptyIconContainer: {
+    marginBottom: 16,
+  },
+  emptyIcon: {
+    marginLeft: 'auto',
+    marginRight: 'auto',
+  },
+  emptyBtn: {
+    marginVertical: 20,
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'space-between',
+    paddingVertical: 18,
+    paddingHorizontal: 24,
+    borderRadius: 30,
+  },
+
+  feedsTip: {
+    position: 'absolute',
+    left: 22,
+  },
+  feedsTipArrow: {
+    marginLeft: 32,
+    marginTop: 8,
+  },
+})
diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx
index f24c3d0c9..5204f5a40 100644
--- a/src/view/com/profile/FollowButton.tsx
+++ b/src/view/com/profile/FollowButton.tsx
@@ -1,16 +1,18 @@
 import React from 'react'
 import {observer} from 'mobx-react-lite'
-import {Button} from '../util/forms/Button'
+import {Button, ButtonType} from '../util/forms/Button'
 import {useStores} from 'state/index'
 import * as apilib from 'lib/api/index'
 import * as Toast from '../util/Toast'
 
 const FollowButton = observer(
   ({
+    type = 'inverted',
     did,
     declarationCid,
     onToggleFollow,
   }: {
+    type?: ButtonType
     did: string
     declarationCid: string
     onToggleFollow?: (v: boolean) => void
@@ -42,7 +44,7 @@ const FollowButton = observer(
 
     return (
       <Button
-        type={isFollowing ? 'default' : 'primary'}
+        type={isFollowing ? 'default' : type}
         onPress={onToggleFollowInner}
         label={isFollowing ? 'Unfollow' : 'Follow'}
       />
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 087536c36..53f45fb11 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -1,6 +1,7 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
+import {AppBskyActorProfile} from '@atproto/api'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
@@ -15,7 +16,9 @@ export function ProfileCard({
   avatar,
   description,
   isFollowedBy,
+  noBg,
   noBorder,
+  followers,
   renderButton,
 }: {
   handle: string
@@ -23,7 +26,9 @@ export function ProfileCard({
   avatar?: string
   description?: string
   isFollowedBy?: boolean
+  noBg?: boolean
   noBorder?: boolean
+  followers?: AppBskyActorProfile.View[] | undefined
   renderButton?: () => JSX.Element
 }) {
   const pal = usePalette('default')
@@ -31,9 +36,9 @@ export function ProfileCard({
     <Link
       style={[
         styles.outer,
-        pal.view,
         pal.border,
         noBorder && styles.outerNoBorder,
+        !noBg && pal.view,
       ]}
       href={`/profile/${handle}`}
       title={handle}
@@ -73,6 +78,25 @@ export function ProfileCard({
           </Text>
         </View>
       ) : undefined}
+      {followers?.length ? (
+        <View style={styles.followedBy}>
+          <Text
+            type="sm"
+            style={[styles.followsByDesc, pal.textLight]}
+            numberOfLines={2}
+            lineHeight={1.2}>
+            Followed by{' '}
+            {followers.map(f => f.displayName || f.handle).join(', ')}
+          </Text>
+          {followers.slice(0, 3).map(f => (
+            <View key={f.did} style={styles.followedByAviContainer}>
+              <View style={[styles.followedByAvi, pal.view]}>
+                <UserAvatar avatar={f.avatar} size={32} />
+              </View>
+            </View>
+          ))}
+        </View>
+      ) : undefined}
     </Link>
   )
 }
@@ -86,6 +110,9 @@ export const ProfileCardWithFollowBtn = observer(
     avatar,
     description,
     isFollowedBy,
+    noBg,
+    noBorder,
+    followers,
   }: {
     did: string
     declarationCid: string
@@ -94,6 +121,9 @@ export const ProfileCardWithFollowBtn = observer(
     avatar?: string
     description?: string
     isFollowedBy?: boolean
+    noBg?: boolean
+    noBorder?: boolean
+    followers?: AppBskyActorProfile.View[] | undefined
   }) => {
     const store = useStores()
     const isMe = store.me.handle === handle
@@ -105,6 +135,9 @@ export const ProfileCardWithFollowBtn = observer(
         avatar={avatar}
         description={description}
         isFollowedBy={isFollowedBy}
+        noBg={noBg}
+        noBorder={noBorder}
+        followers={followers}
         renderButton={
           isMe
             ? undefined
@@ -128,8 +161,8 @@ const styles = StyleSheet.create({
     alignItems: 'center',
   },
   layoutAvi: {
-    width: 60,
-    paddingLeft: 10,
+    width: 54,
+    paddingLeft: 4,
     paddingTop: 8,
     paddingBottom: 10,
   },
@@ -164,4 +197,27 @@ const styles = StyleSheet.create({
     marginLeft: 6,
     paddingHorizontal: 14,
   },
+
+  followedBy: {
+    flexDirection: 'row',
+    alignItems: 'flex-start',
+    paddingLeft: 54,
+    paddingRight: 20,
+    marginBottom: 10,
+    marginTop: -6,
+  },
+  followedByAviContainer: {
+    width: 24,
+    height: 36,
+  },
+  followedByAvi: {
+    width: 36,
+    height: 36,
+    borderRadius: 18,
+    padding: 2,
+  },
+  followsByDesc: {
+    flex: 1,
+    paddingRight: 10,
+  },
 })
diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx
index 9e72640d2..2f653ee09 100644
--- a/src/view/com/util/LoadingPlaceholder.tsx
+++ b/src/view/com/util/LoadingPlaceholder.tsx
@@ -128,6 +128,46 @@ export function NotificationFeedLoadingPlaceholder() {
   )
 }
 
+export function ProfileCardLoadingPlaceholder({
+  style,
+}: {
+  style?: StyleProp<ViewStyle>
+}) {
+  const pal = usePalette('default')
+  return (
+    <View style={[styles.profileCard, pal.view, style]}>
+      <LoadingPlaceholder
+        width={40}
+        height={40}
+        style={styles.profileCardAvi}
+      />
+      <View>
+        <LoadingPlaceholder width={140} height={8} style={[s.mb5]} />
+        <LoadingPlaceholder width={120} height={8} style={[s.mb10]} />
+        <LoadingPlaceholder width={220} height={8} style={[s.mb5]} />
+      </View>
+    </View>
+  )
+}
+
+export function ProfileCardFeedLoadingPlaceholder() {
+  return (
+    <>
+      <ProfileCardLoadingPlaceholder />
+      <ProfileCardLoadingPlaceholder />
+      <ProfileCardLoadingPlaceholder />
+      <ProfileCardLoadingPlaceholder />
+      <ProfileCardLoadingPlaceholder />
+      <ProfileCardLoadingPlaceholder />
+      <ProfileCardLoadingPlaceholder />
+      <ProfileCardLoadingPlaceholder />
+      <ProfileCardLoadingPlaceholder />
+      <ProfileCardLoadingPlaceholder />
+      <ProfileCardLoadingPlaceholder />
+    </>
+  )
+}
+
 const styles = StyleSheet.create({
   loadingPlaceholder: {
     borderRadius: 6,
@@ -147,6 +187,15 @@ const styles = StyleSheet.create({
     paddingLeft: 46,
     margin: 1,
   },
+  profileCard: {
+    flexDirection: 'row',
+    padding: 10,
+    margin: 1,
+  },
+  profileCardAvi: {
+    borderRadius: 20,
+    marginRight: 10,
+  },
   smallAvatar: {
     borderRadius: 15,
     marginRight: 10,
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index 0bb402100..c53de5c1f 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -44,7 +44,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
     // two-liner with follow button
     return (
       <View style={styles.metaTwoLine}>
-        <View>
+        <View style={styles.metaTwoLineLeft}>
           <View style={styles.metaTwoLineTop}>
             <DesktopWebTextLink
               type="lg-bold"
@@ -69,6 +69,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
             type="md"
             style={[styles.metaItem, pal.textLight]}
             lineHeight={1.2}
+            numberOfLines={1}
             text={`@${handle}`}
             href={`/profile/${opts.authorHandle}`}
           />
@@ -76,6 +77,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
 
         <View>
           <FollowButton
+            type="default"
             did={opts.did}
             declarationCid={opts.declarationCid}
             onToggleFollow={onToggleFollow}
@@ -134,7 +136,12 @@ const styles = StyleSheet.create({
     flexDirection: 'row',
     alignItems: 'center',
     justifyContent: 'space-between',
-    paddingBottom: 2,
+    width: '100%',
+    paddingBottom: 4,
+  },
+  metaTwoLineLeft: {
+    flex: 1,
+    paddingRight: 40,
   },
   metaTwoLineTop: {
     flexDirection: 'row',
diff --git a/src/view/com/util/TabBar.tsx b/src/view/com/util/TabBar.tsx
new file mode 100644
index 000000000..4b67b8a80
--- /dev/null
+++ b/src/view/com/util/TabBar.tsx
@@ -0,0 +1,162 @@
+import React, {createRef, useState, useMemo} from 'react'
+import {
+  Animated,
+  StyleSheet,
+  TouchableWithoutFeedback,
+  View,
+} from 'react-native'
+import {Text} from './text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isDesktopWeb} from 'platform/detection'
+
+interface Layout {
+  x: number
+  width: number
+}
+
+export interface TabBarProps {
+  selectedPage: number
+  items: string[]
+  position: Animated.Value
+  offset: Animated.Value
+  indicatorPosition?: 'top' | 'bottom'
+  indicatorColor?: string
+  onSelect?: (index: number) => void
+  onPressSelected?: () => void
+}
+
+export function TabBar({
+  selectedPage,
+  items,
+  position,
+  offset,
+  indicatorPosition = 'bottom',
+  indicatorColor,
+  onSelect,
+  onPressSelected,
+}: TabBarProps) {
+  const pal = usePalette('default')
+  const [itemLayouts, setItemLayouts] = useState<Layout[]>(
+    items.map(() => ({x: 0, width: 0})),
+  )
+  const itemRefs = useMemo(
+    () => Array.from({length: items.length}).map(() => createRef<View>()),
+    [items.length],
+  )
+  const panX = Animated.add(position, offset)
+
+  const indicatorStyle = {
+    backgroundColor: indicatorColor || pal.colors.link,
+    bottom:
+      indicatorPosition === 'bottom' ? (isDesktopWeb ? 0 : -1) : undefined,
+    top: indicatorPosition === 'top' ? (isDesktopWeb ? 0 : -1) : undefined,
+    transform: [
+      {
+        translateX: panX.interpolate({
+          inputRange: items.map((_item, i) => i),
+          outputRange: itemLayouts.map(l => l.x + l.width / 2),
+        }),
+      },
+      {
+        scaleX: panX.interpolate({
+          inputRange: items.map((_item, i) => i),
+          outputRange: itemLayouts.map(l => l.width),
+        }),
+      },
+    ],
+  }
+
+  const onLayout = () => {
+    const promises = []
+    for (let i = 0; i < items.length; i++) {
+      promises.push(
+        new Promise<Layout>(resolve => {
+          itemRefs[i].current?.measure(
+            (x: number, _y: number, width: number) => {
+              resolve({x, width})
+            },
+          )
+        }),
+      )
+    }
+    Promise.all(promises).then((layouts: Layout[]) => {
+      setItemLayouts(layouts)
+    })
+  }
+
+  const onPressItem = (index: number) => {
+    onSelect?.(index)
+    if (index === selectedPage) {
+      onPressSelected?.()
+    }
+  }
+
+  return (
+    <View style={[pal.view, styles.outer]} onLayout={onLayout}>
+      <Animated.View style={[styles.indicator, indicatorStyle]} />
+      {items.map((item, i) => {
+        const selected = i === selectedPage
+        return (
+          <TouchableWithoutFeedback key={i} onPress={() => onPressItem(i)}>
+            <View
+              style={
+                indicatorPosition === 'top' ? styles.itemTop : styles.itemBottom
+              }
+              ref={itemRefs[i]}>
+              <Text type="xl-bold" style={selected ? pal.text : pal.textLight}>
+                {item}
+              </Text>
+            </View>
+          </TouchableWithoutFeedback>
+        )
+      })}
+    </View>
+  )
+}
+
+const styles = isDesktopWeb
+  ? StyleSheet.create({
+      outer: {
+        flexDirection: 'row',
+        paddingHorizontal: 18,
+      },
+      itemTop: {
+        paddingTop: 16,
+        paddingBottom: 14,
+        marginRight: 24,
+      },
+      itemBottom: {
+        paddingTop: 14,
+        paddingBottom: 16,
+        marginRight: 24,
+      },
+      indicator: {
+        position: 'absolute',
+        left: 0,
+        width: 1,
+        height: 3,
+      },
+    })
+  : StyleSheet.create({
+      outer: {
+        flexDirection: 'row',
+        paddingHorizontal: 14,
+      },
+      itemTop: {
+        paddingTop: 10,
+        paddingBottom: 10,
+        marginRight: 24,
+      },
+      itemBottom: {
+        paddingTop: 8,
+        paddingBottom: 12,
+        marginRight: 24,
+      },
+      indicator: {
+        position: 'absolute',
+        left: 0,
+        width: 1,
+        height: 3,
+        borderRadius: 4,
+      },
+    })
diff --git a/src/view/com/util/WelcomeBanner.tsx b/src/view/com/util/WelcomeBanner.tsx
deleted file mode 100644
index 428a30764..000000000
--- a/src/view/com/util/WelcomeBanner.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Text} from './text/Text'
-import {Button} from './forms/Button'
-import {s} from 'lib/styles'
-import {useStores} from 'state/index'
-import {SUGGESTED_FOLLOWS} from 'lib/constants'
-// @ts-ignore no type definition -prf
-import ProgressBar from 'react-native-progress/Bar'
-import {CenteredView} from './Views'
-
-export const WelcomeBanner = observer(() => {
-  const pal = usePalette('default')
-  const store = useStores()
-  const [isReady, setIsReady] = React.useState(false)
-
-  const numFollows = Math.min(
-    SUGGESTED_FOLLOWS(String(store.agent.service)).length,
-    5,
-  )
-  const remaining = numFollows - store.me.follows.numFollows
-
-  React.useEffect(() => {
-    if (remaining <= 0) {
-      // wait 500ms for the progress bar anim to finish
-      const ti = setTimeout(() => {
-        setIsReady(true)
-      }, 500)
-      return () => clearTimeout(ti)
-    } else {
-      setIsReady(false)
-    }
-  }, [remaining])
-
-  const onPressDone = React.useCallback(() => {
-    store.shell.setOnboarding(false)
-  }, [store])
-
-  return (
-    <CenteredView
-      testID="welcomeBanner"
-      style={[pal.view, styles.container, pal.border]}>
-      <Text
-        type="title-lg"
-        style={[pal.text, s.textCenter, s.bold, s.pb5]}
-        lineHeight={1.1}>
-        Welcome to Bluesky!
-      </Text>
-      {isReady ? (
-        <View style={styles.controls}>
-          <Button
-            type="primary"
-            style={[s.flexRow, s.alignCenter]}
-            onPress={onPressDone}>
-            <Text type="md-bold" style={s.white}>
-              See my feed!
-            </Text>
-            <FontAwesomeIcon icon="angle-right" size={14} style={s.white} />
-          </Button>
-        </View>
-      ) : (
-        <>
-          <Text type="lg" style={[pal.text, s.textCenter]}>
-            Follow at least {remaining} {remaining === 1 ? 'person' : 'people'}{' '}
-            to build your feed.
-          </Text>
-          <View style={[styles.controls, styles.progress]}>
-            <ProgressBar
-              progress={Math.max(
-                store.me.follows.numFollows / numFollows,
-                0.05,
-              )}
-            />
-          </View>
-        </>
-      )}
-    </CenteredView>
-  )
-})
-
-const styles = StyleSheet.create({
-  container: {
-    paddingTop: 16,
-    paddingBottom: 16,
-    paddingHorizontal: 20,
-    borderTopWidth: 1,
-    borderBottomWidth: 1,
-  },
-  controls: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    marginTop: 10,
-  },
-  progress: {
-    marginTop: 12,
-  },
-})
diff --git a/src/view/com/util/pager/Pager.tsx b/src/view/com/util/pager/Pager.tsx
new file mode 100644
index 000000000..416828a27
--- /dev/null
+++ b/src/view/com/util/pager/Pager.tsx
@@ -0,0 +1,87 @@
+import React from 'react'
+import {Animated, View} from 'react-native'
+import PagerView, {PagerViewOnPageSelectedEvent} from 'react-native-pager-view'
+import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
+import {s} from 'lib/styles'
+
+export type PageSelectedEvent = PagerViewOnPageSelectedEvent
+const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
+
+export interface RenderTabBarFnProps {
+  selectedPage: number
+  position: Animated.Value
+  offset: Animated.Value
+  onSelect?: (index: number) => void
+}
+export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
+
+interface Props {
+  tabBarPosition?: 'top' | 'bottom'
+  initialPage?: number
+  renderTabBar: RenderTabBarFn
+  onPageSelected?: (index: number) => void
+}
+export const Pager = ({
+  children,
+  tabBarPosition = 'top',
+  initialPage = 0,
+  renderTabBar,
+  onPageSelected,
+}: React.PropsWithChildren<Props>) => {
+  const [selectedPage, setSelectedPage] = React.useState(0)
+  const position = useAnimatedValue(0)
+  const offset = useAnimatedValue(0)
+  const pagerView = React.useRef<PagerView>()
+
+  const onPageSelectedInner = React.useCallback(
+    (e: PageSelectedEvent) => {
+      setSelectedPage(e.nativeEvent.position)
+      onPageSelected?.(e.nativeEvent.position)
+    },
+    [setSelectedPage, onPageSelected],
+  )
+
+  const onTabBarSelect = React.useCallback(
+    (index: number) => {
+      pagerView.current?.setPage(index)
+    },
+    [pagerView],
+  )
+
+  return (
+    <View>
+      {tabBarPosition === 'top' &&
+        renderTabBar({
+          selectedPage,
+          position,
+          offset,
+          onSelect: onTabBarSelect,
+        })}
+      <AnimatedPagerView
+        ref={pagerView}
+        style={s.h100pct}
+        initialPage={initialPage}
+        onPageSelected={onPageSelectedInner}
+        onPageScroll={Animated.event(
+          [
+            {
+              nativeEvent: {
+                position: position,
+                offset: offset,
+              },
+            },
+          ],
+          {useNativeDriver: true},
+        )}>
+        {children}
+      </AnimatedPagerView>
+      {tabBarPosition === 'bottom' &&
+        renderTabBar({
+          selectedPage,
+          position,
+          offset,
+          onSelect: onTabBarSelect,
+        })}
+    </View>
+  )
+}
diff --git a/src/view/com/util/pager/Pager.web.tsx b/src/view/com/util/pager/Pager.web.tsx
new file mode 100644
index 000000000..3c2805833
--- /dev/null
+++ b/src/view/com/util/pager/Pager.web.tsx
@@ -0,0 +1,69 @@
+import React from 'react'
+import {Animated, View} from 'react-native'
+import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
+import {s} from 'lib/styles'
+
+export interface RenderTabBarFnProps {
+  selectedPage: number
+  position: Animated.Value
+  offset: Animated.Value
+  onSelect?: (index: number) => void
+}
+export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
+
+interface Props {
+  tabBarPosition?: 'top' | 'bottom'
+  initialPage?: number
+  renderTabBar: RenderTabBarFn
+  onPageSelected?: (index: number) => void
+}
+export const Pager = ({
+  children,
+  tabBarPosition = 'top',
+  initialPage = 0,
+  renderTabBar,
+  onPageSelected,
+}: React.PropsWithChildren<Props>) => {
+  const [selectedPage, setSelectedPage] = React.useState(initialPage)
+  const position = useAnimatedValue(0)
+  const offset = useAnimatedValue(0)
+
+  const onTabBarSelect = React.useCallback(
+    (index: number) => {
+      setSelectedPage(index)
+      onPageSelected?.(index)
+      Animated.timing(position, {
+        toValue: index,
+        duration: 200,
+        useNativeDriver: true,
+      }).start()
+    },
+    [setSelectedPage, onPageSelected, position],
+  )
+
+  return (
+    <View>
+      {tabBarPosition === 'top' &&
+        renderTabBar({
+          selectedPage,
+          position,
+          offset,
+          onSelect: onTabBarSelect,
+        })}
+      {children.map((child, i) => (
+        <View
+          style={selectedPage === i ? undefined : s.hidden}
+          key={`page-${i}`}>
+          {child}
+        </View>
+      ))}
+      {tabBarPosition === 'bottom' &&
+        renderTabBar({
+          selectedPage,
+          position,
+          offset,
+          onSelect: onTabBarSelect,
+        })}
+    </View>
+  )
+}
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index adc73315c..4950bc0fd 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,26 +1,97 @@
 import React from 'react'
-import {FlatList, View} from 'react-native'
+import {FlatList, View, useWindowDimensions} from 'react-native'
 import {useFocusEffect, useIsFocused} from '@react-navigation/native'
 import {observer} from 'mobx-react-lite'
 import useAppState from 'react-native-appstate-hook'
 import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
+import {FeedModel} from 'state/models/feed-view'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {ViewHeader} from '../com/util/ViewHeader'
 import {Feed} from '../com/posts/Feed'
+import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
 import {LoadLatestBtn} from '../com/util/LoadLatestBtn'
-import {WelcomeBanner} from '../com/util/WelcomeBanner'
+import {FeedsTabBar} from './home/FeedsTabBar'
+import {Pager, RenderTabBarFnProps} from 'view/com/util/pager/Pager'
 import {FAB} from '../com/util/FAB'
 import {useStores} from 'state/index'
 import {s} from 'lib/styles'
 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
 import {useAnalytics} from 'lib/analytics'
 import {ComposeIcon2} from 'lib/icons'
+import {isDesktopWeb} from 'platform/detection'
 
-const HEADER_HEIGHT = 42
+const TAB_BAR_HEIGHT = 82
 
 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
-export const HomeScreen = withAuthRequired(
-  observer(function Home(_opts: Props) {
+export const HomeScreen = withAuthRequired((_opts: Props) => {
+  const store = useStores()
+  const [selectedPage, setSelectedPage] = React.useState(0)
+
+  const algoFeed = React.useMemo(() => {
+    const feed = new FeedModel(store, 'goodstuff', {})
+    feed.setup()
+    return feed
+  }, [store])
+
+  useFocusEffect(
+    React.useCallback(() => {
+      store.shell.setIsDrawerSwipeDisabled(selectedPage > 0)
+      return () => {
+        store.shell.setIsDrawerSwipeDisabled(false)
+      }
+    }, [store, selectedPage]),
+  )
+
+  const onPageSelected = React.useCallback(
+    (index: number) => {
+      setSelectedPage(index)
+      store.shell.setIsDrawerSwipeDisabled(index > 0)
+    },
+    [store],
+  )
+
+  const onPressSelected = React.useCallback(() => {
+    store.emitScreenSoftReset()
+  }, [store])
+
+  const renderTabBar = React.useCallback(
+    (props: RenderTabBarFnProps) => {
+      return <FeedsTabBar {...props} onPressSelected={onPressSelected} />
+    },
+    [onPressSelected],
+  )
+
+  const renderFollowingEmptyState = React.useCallback(() => {
+    return <FollowingEmptyState />
+  }, [])
+
+  const initialPage = store.me.follows.isEmpty ? 1 : 0
+  return (
+    <Pager
+      onPageSelected={onPageSelected}
+      renderTabBar={renderTabBar}
+      tabBarPosition={isDesktopWeb ? 'top' : 'bottom'}
+      initialPage={initialPage}>
+      <FeedPage
+        key="1"
+        isPageFocused={selectedPage === 0}
+        feed={store.me.mainFeed}
+        renderEmptyState={renderFollowingEmptyState}
+      />
+      <FeedPage key="2" isPageFocused={selectedPage === 1} feed={algoFeed} />
+    </Pager>
+  )
+})
+
+const FeedPage = observer(
+  ({
+    isPageFocused,
+    feed,
+    renderEmptyState,
+  }: {
+    feed: FeedModel
+    isPageFocused: boolean
+    renderEmptyState?: () => JSX.Element
+  }) => {
     const store = useStores()
     const onMainScroll = useOnMainScroll(store)
     const {screen, track} = useAnalytics()
@@ -28,38 +99,51 @@ export const HomeScreen = withAuthRequired(
     const {appState} = useAppState({
       onForeground: () => doPoll(true),
     })
-    const isFocused = useIsFocused()
+    const isScreenFocused = useIsFocused()
+    const winDim = useWindowDimensions()
+    const containerStyle = React.useMemo(
+      () => ({height: winDim.height - (isDesktopWeb ? 0 : TAB_BAR_HEIGHT)}),
+      [winDim],
+    )
 
     const doPoll = React.useCallback(
       (knownActive = false) => {
-        if ((!knownActive && appState !== 'active') || !isFocused) {
+        if (
+          (!knownActive && appState !== 'active') ||
+          !isScreenFocused ||
+          !isPageFocused
+        ) {
           return
         }
-        if (store.me.mainFeed.isLoading) {
+        if (feed.isLoading) {
           return
         }
         store.log.debug('HomeScreen: Polling for new posts')
-        store.me.mainFeed.checkForLatest()
+        feed.checkForLatest()
       },
-      [appState, isFocused, store],
+      [appState, isScreenFocused, isPageFocused, store, feed],
     )
 
     const scrollToTop = React.useCallback(() => {
-      // NOTE: the feed is offset by the height of the collapsing header,
-      //       so we scroll to the negative of that height -prf
-      scrollElRef.current?.scrollToOffset({offset: -HEADER_HEIGHT})
+      scrollElRef.current?.scrollToOffset({offset: 0})
     }, [scrollElRef])
 
+    const onSoftReset = React.useCallback(() => {
+      if (isPageFocused) {
+        scrollToTop()
+      }
+    }, [isPageFocused, scrollToTop])
+
     useFocusEffect(
       React.useCallback(() => {
-        const softResetSub = store.onScreenSoftReset(scrollToTop)
-        const feedCleanup = store.me.mainFeed.registerListeners()
+        const softResetSub = store.onScreenSoftReset(onSoftReset)
+        const feedCleanup = feed.registerListeners()
         const pollInterval = setInterval(doPoll, 15e3)
 
         screen('Feed')
         store.log.debug('HomeScreen: Updating feed')
-        if (store.me.mainFeed.hasContent) {
-          store.me.mainFeed.update()
+        if (feed.hasContent) {
+          feed.update()
         }
 
         return () => {
@@ -67,7 +151,7 @@ export const HomeScreen = withAuthRequired(
           softResetSub.remove()
           feedCleanup()
         }
-      }, [store, doPoll, scrollToTop, screen]),
+      }, [store, doPoll, onSoftReset, screen, feed]),
     )
 
     const onPressCompose = React.useCallback(() => {
@@ -76,32 +160,28 @@ export const HomeScreen = withAuthRequired(
     }, [store, track])
 
     const onPressTryAgain = React.useCallback(() => {
-      store.me.mainFeed.refresh()
-    }, [store])
+      feed.refresh()
+    }, [feed])
 
     const onPressLoadLatest = React.useCallback(() => {
-      store.me.mainFeed.refresh()
+      feed.refresh()
       scrollToTop()
-    }, [store, scrollToTop])
+    }, [feed, scrollToTop])
 
     return (
-      <View style={s.hContentRegion}>
-        {store.shell.isOnboarding && <WelcomeBanner />}
+      <View style={containerStyle}>
         <Feed
           testID="homeFeed"
           key="default"
-          feed={store.me.mainFeed}
+          feed={feed}
           scrollElRef={scrollElRef}
           style={s.hContentRegion}
           showPostFollowBtn
           onPressTryAgain={onPressTryAgain}
           onScroll={onMainScroll}
-          headerOffset={store.shell.isOnboarding ? 0 : HEADER_HEIGHT}
+          renderEmptyState={renderEmptyState}
         />
-        {!store.shell.isOnboarding && (
-          <ViewHeader title="Bluesky" canGoBack={false} hideOnScroll />
-        )}
-        {store.me.mainFeed.hasNewLatest && !store.me.mainFeed.isRefreshing && (
+        {feed.hasNewLatest && !feed.isRefreshing && (
           <LoadLatestBtn onPress={onPressLoadLatest} />
         )}
         <FAB
@@ -111,5 +191,5 @@ export const HomeScreen = withAuthRequired(
         />
       </View>
     )
-  }),
+  },
 )
diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx
index 19535a164..6ae5fba0d 100644
--- a/src/view/screens/Search.tsx
+++ b/src/view/screens/Search.tsx
@@ -1,6 +1,7 @@
 import React from 'react'
 import {
   Keyboard,
+  RefreshControl,
   StyleSheet,
   TextInput,
   TouchableOpacity,
@@ -13,21 +14,23 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {ScrollView} from '../com/util/Views'
+import {ScrollView} from 'view/com/util/Views'
 import {
   NativeStackScreenProps,
   SearchTabNavigatorParams,
 } from 'lib/routes/types'
 import {observer} from 'mobx-react-lite'
-import {UserAvatar} from '../com/util/UserAvatar'
-import {Text} from '../com/util/text/Text'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {Text} from 'view/com/util/text/Text'
 import {useStores} from 'state/index'
 import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
+import {FoafsModel} from 'state/models/discovery/foafs'
 import {s} from 'lib/styles'
 import {MagnifyingGlassIcon} from 'lib/icons'
-import {WhoToFollow} from '../com/discover/WhoToFollow'
-import {SuggestedPosts} from '../com/discover/SuggestedPosts'
-import {ProfileCard} from '../com/profile/ProfileCard'
+import {WhoToFollow} from 'view/com/discover/WhoToFollow'
+import {SuggestedFollows} from 'view/com/discover/SuggestedFollows'
+import {ProfileCard} from 'view/com/profile/ProfileCard'
+import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
@@ -53,6 +56,11 @@ export const SearchScreen = withAuthRequired(
       () => new UserAutocompleteViewModel(store),
       [store],
     )
+    const foafsView = React.useMemo<FoafsModel>(
+      () => new FoafsModel(store),
+      [store],
+    )
+    const [refreshing, setRefreshing] = React.useState(false)
 
     const onSoftReset = () => {
       scrollElRef.current?.scrollTo({x: 0, y: 0})
@@ -71,9 +79,12 @@ export const SearchScreen = withAuthRequired(
         }
         store.shell.setMinimalShellMode(false)
         autocompleteView.setup()
+        if (!foafsView.hasData) {
+          foafsView.fetch()
+        }
 
         return cleanup
-      }, [store, autocompleteView, lastRenderTime, setRenderTime]),
+      }, [store, autocompleteView, foafsView, lastRenderTime, setRenderTime]),
     )
 
     const onPressMenu = () => {
@@ -98,15 +109,18 @@ export const SearchScreen = withAuthRequired(
       autocompleteView.setActive(false)
       textInput.current?.blur()
     }
+    const onRefresh = React.useCallback(async () => {
+      setRefreshing(true)
+      try {
+        await foafsView.fetch()
+      } finally {
+        setRefreshing(false)
+      }
+    }, [foafsView, setRefreshing])
 
     return (
       <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
-        <ScrollView
-          ref={scrollElRef}
-          testID="searchScrollView"
-          style={[pal.view, styles.container]}
-          onScroll={onMainScroll}
-          scrollEventThrottle={100}>
+        <View style={[pal.view, styles.container]}>
           <View style={[pal.view, pal.border, styles.header]}>
             <TouchableOpacity
               testID="viewHeaderBackOrMenuBtn"
@@ -180,14 +194,53 @@ export const SearchScreen = withAuthRequired(
               </Text>
             </View>
           ) : (
-            <ScrollView onScroll={Keyboard.dismiss}>
-              <WhoToFollow key={`wtf-${lastRenderTime}`} />
-              <SuggestedPosts key={`sp-${lastRenderTime}`} />
+            <ScrollView
+              ref={scrollElRef}
+              testID="searchScrollView"
+              style={pal.view}
+              onScroll={onMainScroll}
+              scrollEventThrottle={100}
+              refreshControl={
+                <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
+              }>
+              {foafsView.isLoading ? (
+                <ProfileCardFeedLoadingPlaceholder />
+              ) : foafsView.hasContent ? (
+                <>
+                  {foafsView.popular.length > 0 && (
+                    <View style={styles.suggestions}>
+                      <SuggestedFollows
+                        title="In your network"
+                        suggestions={foafsView.popular}
+                      />
+                    </View>
+                  )}
+                  {foafsView.sources.map((source, i) => {
+                    const item = foafsView.foafs.get(source)
+                    if (!item || item.follows.length === 0) {
+                      return <View key={`sf-${item?.did || i}`} />
+                    }
+                    return (
+                      <View key={`sf-${item.did}`} style={styles.suggestions}>
+                        <SuggestedFollows
+                          title={`Followed by ${
+                            item.displayName || item.handle
+                          }`}
+                          suggestions={item.follows.slice(0, 10)}
+                        />
+                      </View>
+                    )
+                  })}
+                </>
+              ) : (
+                <View style={pal.view}>
+                  <WhoToFollow />
+                </View>
+              )}
               <View style={s.footerSpacer} />
             </ScrollView>
           )}
-          <View style={s.footerSpacer} />
-        </ScrollView>
+        </View>
       </TouchableWithoutFeedback>
     )
   }),
@@ -235,4 +288,8 @@ const styles = StyleSheet.create({
     textAlign: 'center',
     paddingTop: 10,
   },
+
+  suggestions: {
+    marginBottom: 8,
+  },
 })
diff --git a/src/view/screens/home/FeedsTabBar.tsx b/src/view/screens/home/FeedsTabBar.tsx
new file mode 100644
index 000000000..d34034103
--- /dev/null
+++ b/src/view/screens/home/FeedsTabBar.tsx
@@ -0,0 +1,72 @@
+import React from 'react'
+import {Animated, StyleSheet} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {TabBar} from 'view/com/util/TabBar'
+import {RenderTabBarFnProps} from 'view/com/util/pager/Pager'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
+import {clamp} from 'lodash'
+
+const BOTTOM_BAR_HEIGHT = 48
+
+export const FeedsTabBar = observer(
+  (props: RenderTabBarFnProps & {onPressSelected: () => void}) => {
+    const store = useStores()
+    const safeAreaInsets = useSafeAreaInsets()
+    const pal = usePalette('default')
+    const interp = useAnimatedValue(0)
+
+    const pad = React.useMemo(
+      () => ({
+        paddingBottom: clamp(safeAreaInsets.bottom, 15, 20),
+      }),
+      [safeAreaInsets],
+    )
+
+    React.useEffect(() => {
+      Animated.timing(interp, {
+        toValue: store.shell.minimalShellMode ? 0 : 1,
+        duration: 100,
+        useNativeDriver: true,
+        isInteraction: false,
+      }).start()
+    }, [interp, store.shell.minimalShellMode])
+    const transform = {
+      transform: [
+        {translateY: Animated.multiply(interp, -1 * BOTTOM_BAR_HEIGHT)},
+      ],
+    }
+
+    return (
+      <Animated.View
+        style={[pal.view, pal.border, styles.tabBar, pad, transform]}>
+        <TabBar
+          {...props}
+          items={['Following', "What's hot"]}
+          indicatorPosition="top"
+          indicatorColor={pal.colors.link}
+        />
+      </Animated.View>
+    )
+  },
+)
+
+const styles = StyleSheet.create({
+  tabBar: {
+    position: 'absolute',
+    left: 0,
+    right: 0,
+    bottom: 0,
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 10,
+    borderTopWidth: 1,
+    paddingTop: 0,
+    paddingBottom: 30,
+  },
+  tabBarAvi: {
+    marginRight: 4,
+  },
+})
diff --git a/src/view/screens/home/FeedsTabBar.web.tsx b/src/view/screens/home/FeedsTabBar.web.tsx
new file mode 100644
index 000000000..59ea42988
--- /dev/null
+++ b/src/view/screens/home/FeedsTabBar.web.tsx
@@ -0,0 +1,22 @@
+import React from 'react'
+import {observer} from 'mobx-react-lite'
+import {TabBar} from 'view/com/util/TabBar'
+import {CenteredView} from 'view/com/util/Views'
+import {RenderTabBarFnProps} from 'view/com/util/pager/Pager'
+import {usePalette} from 'lib/hooks/usePalette'
+
+export const FeedsTabBar = observer(
+  (props: RenderTabBarFnProps & {onPressSelected: () => void}) => {
+    const pal = usePalette('default')
+    return (
+      <CenteredView>
+        <TabBar
+          {...props}
+          items={['Following', "What's hot"]}
+          indicatorPosition="bottom"
+          indicatorColor={pal.colors.link}
+        />
+      </CenteredView>
+    )
+  },
+)
diff --git a/src/view/shell/BottomBar.tsx b/src/view/shell/BottomBar.tsx
index c59d8c675..1cbf2de86 100644
--- a/src/view/shell/BottomBar.tsx
+++ b/src/view/shell/BottomBar.tsx
@@ -34,16 +34,24 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
   const minimalShellInterp = useAnimatedValue(0)
   const safeAreaInsets = useSafeAreaInsets()
   const {track} = useAnalytics()
-  const {isAtHome, isAtSearch, isAtNotifications} = useNavigationState(
-    state => {
-      return {
+  const {isAtHome, isAtSearch, isAtNotifications, noBorder} =
+    useNavigationState(state => {
+      const res = {
         isAtHome: getTabState(state, 'Home') !== TabState.Outside,
         isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
         isAtNotifications:
           getTabState(state, 'Notifications') !== TabState.Outside,
+        noBorder: getTabState(state, 'Home') === TabState.InsideAtRoot,
       }
-    },
-  )
+      if (!res.isAtHome && !res.isAtNotifications && !res.isAtSearch) {
+        // HACK for some reason useNavigationState will give us pre-hydration results
+        //      and not update after, so we force isAtHome if all came back false
+        //      -prf
+        res.isAtHome = true
+        res.noBorder = true
+      }
+      return res
+    })
 
   React.useEffect(() => {
     if (store.shell.minimalShellMode) {
@@ -99,6 +107,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
     <Animated.View
       style={[
         styles.bottomBar,
+        noBorder && styles.noBorder,
         pal.view,
         pal.border,
         {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
@@ -213,6 +222,9 @@ const styles = StyleSheet.create({
     paddingLeft: 5,
     paddingRight: 10,
   },
+  noBorder: {
+    borderTopWidth: 0,
+  },
   ctrl: {
     flex: 1,
     paddingTop: 13,
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index d7877804b..eec0f8ed4 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -46,7 +46,11 @@ const ShellInner = observer(() => {
             onOpen={onOpenDrawer}
             onClose={onCloseDrawer}
             swipeEdgeWidth={winDim.width}
-            swipeEnabled={!canGoBack && store.session.hasSession}>
+            swipeEnabled={
+              !canGoBack &&
+              store.session.hasSession &&
+              !store.shell.isDrawerSwipeDisabled
+            }>
             <TabsNavigator />
           </Drawer>
         </ErrorBoundary>