about summary refs log tree commit diff
diff options
context:
space:
mode:
authorAnsh <anshnanda10@gmail.com>2023-03-02 10:21:33 -0800
committerGitHub <noreply@github.com>2023-03-02 12:21:33 -0600
commitbd9386d81c258d3d3f43666d3e25328f68428689 (patch)
tree8008c5dcfc41f85aac24abac0f6fec08dea6296f
parent9b46b2e6a9a8e4e9254fa9031b2eb44a672e287f (diff)
downloadvoidsky-bd9386d81c258d3d3f43666d3e25328f68428689.tar.zst
New onboarding (#241)
* delete old onboarding files and code

* add custom FollowButton component to Post, FeedItem, & ProfileCard

* move building suggested feed into helper lib

* show suggested posts/feed if follower list is empty

* Update tsconfig.json

* add pagination to getting new onboarding

* remove unnecessary console log

* fix naming, add better null check for combinedCursor

* In locally-combined feeds, correctly produce an undefined cursor when out of data

* Minor refactors of the suggested posts lib functions

* Show 'follow button' style of post meta in certain conditions only

* Only show follow btn in posts on the main feed and the discovery feed

* Add a welcome notice to the home feed

* Tune the timing of when the welcome banner shows or hides

* Make the follow button an observer (closes #244)

* Update postmeta to keep the follow btn after press until next render

* A couple of fixes that ensure consistent welcome screen

* Fix lint

* Rework the welcome banner

* Fix cache invalidation of follows model on user switch

* Show welcome banner while loading

* Update the home onboarding feed to get top posts from hardcode recommends

* Drop unused helper function

* Update happy path tests

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
-rw-r--r--__tests__/state/models/onboard.test.ts46
-rw-r--r--e2e/tests/happyPath.test.js11
-rw-r--r--ios/Podfile.lock16
-rw-r--r--src/lib/api/build-suggested-posts.ts120
-rw-r--r--src/lib/constants.ts36
-rw-r--r--src/lib/styles.ts4
-rw-r--r--src/state/models/feed-view.ts28
-rw-r--r--src/state/models/me.ts1
-rw-r--r--src/state/models/my-follows.ts7
-rw-r--r--src/state/models/onboard.ts65
-rw-r--r--src/state/models/root-store.ts6
-rw-r--r--src/state/models/session.ts1
-rw-r--r--src/state/models/suggested-actors-view.ts22
-rw-r--r--src/state/models/suggested-posts-view.ts92
-rw-r--r--src/view/com/discover/SuggestedPosts.tsx2
-rw-r--r--src/view/com/onboard/FeatureExplainer.tsx196
-rw-r--r--src/view/com/onboard/FeatureExplainer.web.tsx202
-rw-r--r--src/view/com/onboard/Follows.tsx55
-rw-r--r--src/view/com/onboard/Follows.web.tsx47
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx2
-rw-r--r--src/view/com/post/Post.tsx2
-rw-r--r--src/view/com/posts/Feed.tsx41
-rw-r--r--src/view/com/posts/FeedItem.tsx5
-rw-r--r--src/view/com/profile/FollowButton.tsx57
-rw-r--r--src/view/com/profile/ProfileCard.tsx49
-rw-r--r--src/view/com/util/PostMeta.tsx80
-rw-r--r--src/view/com/util/WelcomeBanner.tsx33
-rw-r--r--src/view/screens/Home.tsx4
-rw-r--r--src/view/screens/Onboard.tsx40
-rw-r--r--src/view/shell/mobile/index.tsx12
-rw-r--r--src/view/shell/web/index.tsx10
31 files changed, 426 insertions, 866 deletions
diff --git a/__tests__/state/models/onboard.test.ts b/__tests__/state/models/onboard.test.ts
deleted file mode 100644
index 02ee0feb6..000000000
--- a/__tests__/state/models/onboard.test.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import {
-  OnboardModel,
-  OnboardStageOrder,
-} from '../../../src/state/models/onboard'
-
-describe('OnboardModel', () => {
-  let onboardModel: OnboardModel
-
-  beforeEach(() => {
-    onboardModel = new OnboardModel()
-  })
-
-  afterAll(() => {
-    jest.clearAllMocks()
-  })
-
-  it('should start/stop correctly', () => {
-    onboardModel.start()
-    expect(onboardModel.isOnboarding).toBe(true)
-    onboardModel.stop()
-    expect(onboardModel.isOnboarding).toBe(false)
-  })
-
-  it('should call the next method until it has no more stages', () => {
-    onboardModel.start()
-    onboardModel.next()
-    expect(onboardModel.stage).toBe(OnboardStageOrder[1])
-
-    onboardModel.next()
-    expect(onboardModel.isOnboarding).toBe(false)
-    expect(onboardModel.stage).toBe(OnboardStageOrder[0])
-  })
-
-  it('serialize and hydrate', () => {
-    const serialized = onboardModel.serialize()
-    const newModel = new OnboardModel()
-    newModel.hydrate(serialized)
-    expect(newModel).toEqual(onboardModel)
-
-    onboardModel.start()
-    onboardModel.next()
-    const serialized2 = onboardModel.serialize()
-    newModel.hydrate(serialized2)
-    expect(newModel).toEqual(onboardModel)
-  })
-})
diff --git a/e2e/tests/happyPath.test.js b/e2e/tests/happyPath.test.js
index 15d0b3e32..4176cecb9 100644
--- a/e2e/tests/happyPath.test.js
+++ b/e2e/tests/happyPath.test.js
@@ -1,6 +1,6 @@
 /* eslint-env detox/detox */
 
-describe('Example', () => {
+describe('Happy paths', () => {
   async function grantAccessToUserWithValidCredentials(
     username,
     {takeScreenshots} = {takeScreenshots: false},
@@ -65,13 +65,6 @@ describe('Example', () => {
     await element(by.id('registerIs13Input')).tap()
     await device.takeScreenshot('4- entered account details')
     await element(by.id('createAccountButton')).tap()
-    await expect(element(by.id('onboardFeatureExplainerSkipBtn'))).toBeVisible()
-    await expect(element(by.id('onboardFeatureExplainerNextBtn'))).toBeVisible()
-    await device.takeScreenshot('5- onboard feature explainer')
-    await element(by.id('onboardFeatureExplainerSkipBtn')).tap()
-    await expect(element(by.id('onboardFollowsSkipBtn'))).toBeVisible()
-    await expect(element(by.id('onboardFollowsNextBtn'))).toBeVisible()
-    await device.takeScreenshot('6- onboard follows recommender')
-    await element(by.id('onboardFollowsSkipBtn')).tap()
+    await expect(element(by.id('welcomeBanner'))).toBeVisible()
   })
 })
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index fe16f1414..c9d0aee02 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -26,9 +26,9 @@ PODS:
   - libwebp/mux (1.2.4):
     - libwebp/demux
   - libwebp/webp (1.2.4)
-  - Permission-Camera (3.6.1):
+  - Permission-Camera (3.7.2):
     - RNPermissions
-  - Permission-PhotoLibrary (3.6.1):
+  - Permission-PhotoLibrary (3.7.2):
     - RNPermissions
   - RCT-Folly (2021.07.22.00):
     - boost
@@ -359,7 +359,7 @@ PODS:
     - React-Core
   - RNCAsyncStorage (1.17.11):
     - React-Core
-  - RNCClipboard (1.11.1):
+  - RNCClipboard (1.11.2):
     - React-Core
   - RNFastImage (8.6.3):
     - React-Core
@@ -385,7 +385,7 @@ PODS:
     - RNNotifee/NotifeeCore (= 7.5.0)
   - RNNotifee/NotifeeCore (7.5.0):
     - React-Core
-  - RNPermissions (3.6.1):
+  - RNPermissions (3.7.2):
     - React-Core
   - RNReactNativeHapticFeedback (1.14.0):
     - React-Core
@@ -652,8 +652,8 @@ SPEC CHECKSUMS:
   hermes-engine: 922ccd744f50d9bfde09e9677bf0f3b562ea5fb9
   libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
   libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
-  Permission-Camera: bf6791b17c7f614b6826019fcfdcc286d3a107f6
-  Permission-PhotoLibrary: 5b34ca67279f7201ae109cef36f9806a6596002d
+  Permission-Camera: db22e80aa0858a8b6d65979a97f2f481dd8a0ebd
+  Permission-PhotoLibrary: 7d80161682e08042fd8b0bf934ea97a8495e0e6a
   RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
   RCTRequired: fd4d923b964658aa0c4091a32c8b2004c6d9e3a6
   RCTTypeSafety: c276d85975bde3d8448907235c70bf0da257adfd
@@ -693,14 +693,14 @@ SPEC CHECKSUMS:
   rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
   RNBackgroundFetch: 8e16176ff415daac743a6eb57afc8e9e14dbe623
   RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
-  RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd
+  RNCClipboard: 3f0451a8100393908bea5c5c5b16f96d45f30bfc
   RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8
   RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
   RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39
   RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
   RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
   RNNotifee: 053c0ace9c73634709a0214fd9c436a5777a562f
-  RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c
+  RNPermissions: 2fbbcb7244357507f958d626d58eb15fb0013d85
   RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
   RNReanimated: cc5e3aa479cb9170bcccf8204291a6950a3be128
   RNScreens: 218801c16a2782546d30bd2026bb625c0302d70f
diff --git a/src/lib/api/build-suggested-posts.ts b/src/lib/api/build-suggested-posts.ts
new file mode 100644
index 000000000..6250f4a9c
--- /dev/null
+++ b/src/lib/api/build-suggested-posts.ts
@@ -0,0 +1,120 @@
+import {RootStoreModel} from 'state/index'
+import {
+  AppBskyFeedFeedViewPost,
+  AppBskyFeedGetAuthorFeed as GetAuthorFeed,
+} from '@atproto/api'
+type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
+
+async function getMultipleAuthorsPosts(
+  rootStore: RootStoreModel,
+  authors: string[],
+  cursor: string | undefined = undefined,
+  limit: number = 10,
+) {
+  const responses = await Promise.all(
+    authors.map((author, index) =>
+      rootStore.api.app.bsky.feed
+        .getAuthorFeed({
+          author,
+          limit,
+          before: cursor ? cursor.split(',')[index] : undefined,
+        })
+        .catch(_err => ({success: false, headers: {}, data: {feed: []}})),
+    ),
+  )
+  return responses
+}
+
+function mergePosts(
+  responses: GetAuthorFeed.Response[],
+  {repostsOnly, bestOfOnly}: {repostsOnly?: boolean; bestOfOnly?: boolean},
+) {
+  let posts: AppBskyFeedFeedViewPost.Main[] = []
+
+  if (bestOfOnly) {
+    for (const res of responses) {
+      if (res.success) {
+        // filter the feed down to the post with the most upvotes
+        res.data.feed = res.data.feed.reduce(
+          (acc: AppBskyFeedFeedViewPost.Main[], v) => {
+            if (!acc?.[0] && !v.reason) {
+              return [v]
+            }
+            if (
+              acc &&
+              !v.reason &&
+              v.post.upvoteCount > acc[0].post.upvoteCount
+            ) {
+              return [v]
+            }
+            return acc
+          },
+          [],
+        )
+      }
+    }
+  }
+
+  // merge into one array
+  for (const res of responses) {
+    if (res.success) {
+      posts = posts.concat(res.data.feed)
+    }
+  }
+
+  // filter down to reposts of other users
+  const uris = new Set()
+  posts = posts.filter(p => {
+    if (repostsOnly && !isARepostOfSomeoneElse(p)) {
+      return false
+    }
+    if (uris.has(p.post.uri)) {
+      return false
+    }
+    uris.add(p.post.uri)
+    return true
+  })
+
+  // sort by index time
+  posts.sort((a, b) => {
+    return (
+      Number(new Date(b.post.indexedAt)) - Number(new Date(a.post.indexedAt))
+    )
+  })
+
+  return posts
+}
+
+function isARepostOfSomeoneElse(post: AppBskyFeedFeedViewPost.Main): boolean {
+  return (
+    post.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost' &&
+    post.post.author.did !== (post.reason as ReasonRepost).by.did
+  )
+}
+
+function getCombinedCursors(responses: GetAuthorFeed.Response[]) {
+  let hasCursor = false
+  const cursors = responses.map(r => {
+    if (r.data.cursor) {
+      hasCursor = true
+      return r.data.cursor
+    }
+    return ''
+  })
+  if (!hasCursor) {
+    return undefined
+  }
+  const combinedCursors = cursors.join(',')
+  return combinedCursors
+}
+
+function isCombinedCursor(cursor: string) {
+  return cursor.includes(',')
+}
+
+export {
+  getMultipleAuthorsPosts,
+  mergePosts,
+  getCombinedCursors,
+  isCombinedCursor,
+}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 062fc1aa8..a93301b34 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -4,6 +4,31 @@ export const FEEDBACK_FORM_URL =
 export const MAX_DISPLAY_NAME = 64
 export const MAX_DESCRIPTION = 256
 
+export const PROD_TEAM_HANDLES = [
+  'jay.bsky.social',
+  'paul.bsky.social',
+  'dan.bsky.social',
+  'divy.bsky.social',
+  'why.bsky.social',
+  'iamrosewang.bsky.social',
+]
+export const STAGING_TEAM_HANDLES = [
+  'arcalinea.staging.bsky.dev',
+  'paul.staging.bsky.dev',
+  'paul2.staging.bsky.dev',
+]
+export const DEV_TEAM_HANDLES = ['alice.test', 'bob.test', 'carla.test']
+
+export function TEAM_HANDLES(serviceUrl: string) {
+  if (serviceUrl.includes('localhost')) {
+    return DEV_TEAM_HANDLES
+  } else if (serviceUrl.includes('staging')) {
+    return STAGING_TEAM_HANDLES
+  } else {
+    return PROD_TEAM_HANDLES
+  }
+}
+
 export const PROD_SUGGESTED_FOLLOWS = [
   'john',
   'visakanv',
@@ -55,14 +80,21 @@ export const PROD_SUGGESTED_FOLLOWS = [
   'jay',
   'paul',
 ].map(handle => `${handle}.bsky.social`)
-
 export const STAGING_SUGGESTED_FOLLOWS = ['arcalinea', 'paul', 'paul2'].map(
   handle => `${handle}.staging.bsky.dev`,
 )
-
 export const DEV_SUGGESTED_FOLLOWS = ['alice', 'bob', 'carla'].map(
   handle => `${handle}.test`,
 )
+export function SUGGESTED_FOLLOWS(serviceUrl: string) {
+  if (serviceUrl.includes('localhost')) {
+    return DEV_SUGGESTED_FOLLOWS
+  } else if (serviceUrl.includes('staging')) {
+    return STAGING_SUGGESTED_FOLLOWS
+  } else {
+    return PROD_SUGGESTED_FOLLOWS
+  }
+}
 
 export const POST_IMG_MAX_WIDTH = 2000
 export const POST_IMG_MAX_HEIGHT = 2000
diff --git a/src/lib/styles.ts b/src/lib/styles.ts
index dd3c86910..f6e26d53f 100644
--- a/src/lib/styles.ts
+++ b/src/lib/styles.ts
@@ -62,6 +62,10 @@ export const s = StyleSheet.create({
   footerSpacer: {height: 100},
   contentContainer: {paddingBottom: 200},
   border1: {borderWidth: 1},
+  borderTop1: {borderTopWidth: 1},
+  borderRight1: {borderRightWidth: 1},
+  borderBottom1: {borderBottomWidth: 1},
+  borderLeft1: {borderLeftWidth: 1},
 
   // font weights
   fw600: {fontWeight: '600'},
diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts
index f80c5f2c0..645b1f2eb 100644
--- a/src/state/models/feed-view.ts
+++ b/src/state/models/feed-view.ts
@@ -15,6 +15,12 @@ import {RootStoreModel} from './root-store'
 import * as apilib from 'lib/api/index'
 import {cleanError} from 'lib/strings/errors'
 import {RichText} from 'lib/strings/rich-text'
+import {SUGGESTED_FOLLOWS} from 'lib/constants'
+import {
+  getCombinedCursors,
+  getMultipleAuthorsPosts,
+  mergePosts,
+} from 'lib/api/build-suggested-posts'
 
 const PAGE_SIZE = 30
 
@@ -535,11 +541,31 @@ export class FeedModel {
     }
   }
 
-  protected _getFeed(
+  protected async _getFeed(
     params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {},
   ): Promise<GetTimeline.Response | GetAuthorFeed.Response> {
     params = Object.assign({}, this.params, params)
     if (this.feedType === 'home') {
+      await this.rootStore.me.follows.fetchIfNeeded()
+      if (this.rootStore.me.follows.isEmpty) {
+        const responses = await getMultipleAuthorsPosts(
+          this.rootStore,
+          SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)),
+          params.before,
+          20,
+        )
+        const combinedCursor = getCombinedCursors(responses)
+        const finalData = mergePosts(responses, {bestOfOnly: true})
+        const lastHeaders = responses[responses.length - 1].headers
+        return {
+          success: true,
+          data: {
+            feed: finalData,
+            cursor: combinedCursor,
+          },
+          headers: lastHeaders,
+        }
+      }
       return this.rootStore.api.app.bsky.feed.getTimeline(
         params as GetTimeline.QueryParams,
       )
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index 0cb84c9fc..451d562a4 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -96,6 +96,7 @@ export class MeModel {
           this.avatar = ''
         }
       })
+      this.mainFeed.clear()
       await Promise.all([
         this.mainFeed.setup().catch(e => {
           this.rootStore.log.error('Failed to setup main feed model', e)
diff --git a/src/state/models/my-follows.ts b/src/state/models/my-follows.ts
index 252e8a3d3..c1fba1352 100644
--- a/src/state/models/my-follows.ts
+++ b/src/state/models/my-follows.ts
@@ -20,6 +20,7 @@ export class MyFollowsModel {
   // data
   followDidToRecordMap: Record<string, string> = {}
   lastSync = 0
+  myDid?: string
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(
@@ -36,6 +37,7 @@ export class MyFollowsModel {
 
   fetchIfNeeded = bundleAsync(async () => {
     if (
+      this.myDid !== this.rootStore.me.did ||
       Object.keys(this.followDidToRecordMap).length === 0 ||
       Date.now() - this.lastSync > CACHE_TTL
     ) {
@@ -62,6 +64,7 @@ export class MyFollowsModel {
         this.followDidToRecordMap[record.value.subject.did] = record.uri
       }
       this.lastSync = Date.now()
+      this.myDid = this.rootStore.me.did
     })
   })
 
@@ -69,6 +72,10 @@ export class MyFollowsModel {
     return !!this.followDidToRecordMap[did]
   }
 
+  get isEmpty() {
+    return Object.keys(this.followDidToRecordMap).length === 0
+  }
+
   getFollowUri(did: string): string {
     const v = this.followDidToRecordMap[did]
     if (!v) {
diff --git a/src/state/models/onboard.ts b/src/state/models/onboard.ts
deleted file mode 100644
index aa275c6b7..000000000
--- a/src/state/models/onboard.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import {makeAutoObservable} from 'mobx'
-import {isObj, hasProp} from 'lib/type-guards'
-
-export const OnboardStage = {
-  Explainers: 'explainers',
-  Follows: 'follows',
-}
-
-export const OnboardStageOrder = [OnboardStage.Explainers, OnboardStage.Follows]
-
-export class OnboardModel {
-  isOnboarding: boolean = false
-  stage: string = OnboardStageOrder[0]
-
-  constructor() {
-    makeAutoObservable(this, {
-      serialize: false,
-      hydrate: false,
-    })
-  }
-
-  serialize(): unknown {
-    return {
-      isOnboarding: this.isOnboarding,
-      stage: this.stage,
-    }
-  }
-
-  hydrate(v: unknown) {
-    if (isObj(v)) {
-      if (hasProp(v, 'isOnboarding') && typeof v.isOnboarding === 'boolean') {
-        this.isOnboarding = v.isOnboarding
-      }
-      if (
-        hasProp(v, 'stage') &&
-        typeof v.stage === 'string' &&
-        OnboardStageOrder.includes(v.stage)
-      ) {
-        this.stage = v.stage
-      }
-    }
-  }
-
-  start() {
-    this.isOnboarding = true
-  }
-
-  stop() {
-    this.isOnboarding = false
-  }
-
-  next() {
-    if (!this.isOnboarding) {
-      return
-    }
-    let i = OnboardStageOrder.indexOf(this.stage)
-    i++
-    if (i >= OnboardStageOrder.length) {
-      this.isOnboarding = false
-      this.stage = OnboardStageOrder[0] // in case they make a new account
-    } else {
-      this.stage = OnboardStageOrder[i]
-    }
-  }
-}
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index 43523b759..4b62f501e 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -17,7 +17,6 @@ import {ProfilesViewModel} from './profiles-view'
 import {LinkMetasViewModel} from './link-metas-view'
 import {NotificationsViewItemModel} from './notifications-view'
 import {MeModel} from './me'
-import {OnboardModel} from './onboard'
 
 export const appInfo = z.object({
   build: z.string(),
@@ -35,7 +34,6 @@ export class RootStoreModel {
   nav = new NavigationModel(this)
   shell = new ShellUiModel(this)
   me = new MeModel(this)
-  onboard = new OnboardModel()
   profiles = new ProfilesViewModel(this)
   linkMetas = new LinkMetasViewModel(this)
 
@@ -85,7 +83,6 @@ export class RootStoreModel {
       session: this.session.serialize(),
       me: this.me.serialize(),
       nav: this.nav.serialize(),
-      onboard: this.onboard.serialize(),
       shell: this.shell.serialize(),
     }
   }
@@ -107,9 +104,6 @@ export class RootStoreModel {
       if (hasProp(v, 'nav')) {
         this.nav.hydrate(v.nav)
       }
-      if (hasProp(v, 'onboard')) {
-        this.onboard.hydrate(v.onboard)
-      }
       if (hasProp(v, 'session')) {
         this.session.hydrate(v.session)
       }
diff --git a/src/state/models/session.ts b/src/state/models/session.ts
index 6e816120d..75a60f353 100644
--- a/src/state/models/session.ts
+++ b/src/state/models/session.ts
@@ -345,7 +345,6 @@ export class SessionModel {
     )
 
     this.setActiveSession(agent, did)
-    this.rootStore.onboard.start()
     this.rootStore.log.debug('SessionModel:createAccount succeeded')
   }
 
diff --git a/src/state/models/suggested-actors-view.ts b/src/state/models/suggested-actors-view.ts
index 4764f581e..33c73b4e1 100644
--- a/src/state/models/suggested-actors-view.ts
+++ b/src/state/models/suggested-actors-view.ts
@@ -4,26 +4,12 @@ import shuffle from 'lodash.shuffle'
 import {RootStoreModel} from './root-store'
 import {cleanError} from 'lib/strings/errors'
 import {bundleAsync} from 'lib/async/bundle'
-import {
-  DEV_SUGGESTED_FOLLOWS,
-  PROD_SUGGESTED_FOLLOWS,
-  STAGING_SUGGESTED_FOLLOWS,
-} from 'lib/constants'
+import {SUGGESTED_FOLLOWS} from 'lib/constants'
 
 const PAGE_SIZE = 30
 
 export type SuggestedActor = Profile.ViewBasic | Profile.View
 
-const getSuggestionList = ({serviceUrl}: {serviceUrl: string}) => {
-  if (serviceUrl.includes('localhost')) {
-    return DEV_SUGGESTED_FOLLOWS
-  } else if (serviceUrl.includes('staging')) {
-    return STAGING_SUGGESTED_FOLLOWS
-  } else {
-    return PROD_SUGGESTED_FOLLOWS
-  }
-}
-
 export class SuggestedActorsViewModel {
   // state
   pageSize = PAGE_SIZE
@@ -126,9 +112,9 @@ export class SuggestedActorsViewModel {
     try {
       // clone the array so we can mutate it
       const actors = [
-        ...getSuggestionList({
-          serviceUrl: this.rootStore.session.currentSession?.service || '',
-        }),
+        ...SUGGESTED_FOLLOWS(
+          this.rootStore.session.currentSession?.service || '',
+        ),
       ]
 
       // fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`)
diff --git a/src/state/models/suggested-posts-view.ts b/src/state/models/suggested-posts-view.ts
index 7b44370de..c6710c44e 100644
--- a/src/state/models/suggested-posts-view.ts
+++ b/src/state/models/suggested-posts-view.ts
@@ -1,21 +1,12 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {
-  AppBskyFeedFeedViewPost,
-  AppBskyFeedGetAuthorFeed as GetAuthorFeed,
-} from '@atproto/api'
-type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
 import {RootStoreModel} from './root-store'
 import {FeedItemModel} from './feed-view'
 import {cleanError} from 'lib/strings/errors'
-
-const TEAM_HANDLES = [
-  'jay.bsky.social',
-  'paul.bsky.social',
-  'dan.bsky.social',
-  'divy.bsky.social',
-  'why.bsky.social',
-  'iamrosewang.bsky.social',
-]
+import {TEAM_HANDLES} from 'lib/constants'
+import {
+  getMultipleAuthorsPosts,
+  mergePosts,
+} from 'lib/api/build-suggested-posts'
 
 export class SuggestedPostsView {
   // state
@@ -54,15 +45,18 @@ export class SuggestedPostsView {
   async setup() {
     this._xLoading()
     try {
-      const responses = await Promise.all(
-        TEAM_HANDLES.map(handle =>
-          this.rootStore.api.app.bsky.feed
-            .getAuthorFeed({author: handle, limit: 10})
-            .catch(_err => ({success: false, headers: {}, data: {feed: []}})),
-        ),
+      const responses = await getMultipleAuthorsPosts(
+        this.rootStore,
+        TEAM_HANDLES(String(this.rootStore.agent.service)),
       )
       runInAction(() => {
-        this.posts = mergeAndFilterResponses(this.rootStore, responses)
+        const finalPosts = mergePosts(responses, {repostsOnly: true})
+        // hydrate into models
+        this.posts = finalPosts.map((post, i) => {
+          // strip the reasons to hide that these are reposts
+          delete post.reason
+          return new FeedItemModel(this.rootStore, `post-${i}`, post)
+        })
       })
       this._xIdle()
     } catch (e: any) {
@@ -90,59 +84,3 @@ export class SuggestedPostsView {
     }
   }
 }
-
-function mergeAndFilterResponses(
-  store: RootStoreModel,
-  responses: GetAuthorFeed.Response[],
-): FeedItemModel[] {
-  let posts: AppBskyFeedFeedViewPost.Main[] = []
-
-  // merge into one array
-  for (const res of responses) {
-    if (res.success) {
-      posts = posts.concat(res.data.feed)
-    }
-  }
-
-  // filter down to reposts of other users
-  const now = Date.now()
-  const uris = new Set()
-  posts = posts.filter(p => {
-    if (isARepostOfSomeoneElse(p) && isRecentEnough(now, p)) {
-      if (uris.has(p.post.uri)) {
-        return false
-      }
-      uris.add(p.post.uri)
-      return true
-    }
-    return false
-  })
-
-  // sort by index time
-  posts.sort((a, b) => {
-    return (
-      Number(new Date(b.post.indexedAt)) - Number(new Date(a.post.indexedAt))
-    )
-  })
-
-  // hydrate into models and strip the reasons to hide that these are reposts
-  return posts.map((post, i) => {
-    delete post.reason
-    return new FeedItemModel(store, `post-${i}`, post)
-  })
-}
-
-function isARepostOfSomeoneElse(post: AppBskyFeedFeedViewPost.Main): boolean {
-  return (
-    post.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost' &&
-    post.post.author.did !== (post.reason as ReasonRepost).by.did
-  )
-}
-
-const THREE_DAYS = 3 * 24 * 60 * 60 * 1000
-function isRecentEnough(
-  now: number,
-  post: AppBskyFeedFeedViewPost.Main,
-): boolean {
-  return now - Number(new Date(post.post.indexedAt)) < THREE_DAYS
-}
diff --git a/src/view/com/discover/SuggestedPosts.tsx b/src/view/com/discover/SuggestedPosts.tsx
index 86a6bd394..9c7745dfa 100644
--- a/src/view/com/discover/SuggestedPosts.tsx
+++ b/src/view/com/discover/SuggestedPosts.tsx
@@ -33,7 +33,7 @@ export const SuggestedPosts = observer(() => {
         <>
           <View style={[pal.border, styles.bottomBorder]}>
             {suggestedPostsView.posts.map(item => (
-              <Post item={item} key={item._reactKey} />
+              <Post item={item} key={item._reactKey} showFollowBtn />
             ))}
           </View>
         </>
diff --git a/src/view/com/onboard/FeatureExplainer.tsx b/src/view/com/onboard/FeatureExplainer.tsx
deleted file mode 100644
index 323b1ba14..000000000
--- a/src/view/com/onboard/FeatureExplainer.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-import React, {useState} from 'react'
-import {
-  Animated,
-  Image,
-  SafeAreaView,
-  StyleSheet,
-  TouchableOpacity,
-  useWindowDimensions,
-  View,
-} from 'react-native'
-import {TabView, SceneMap, Route, TabBarProps} from 'react-native-tab-view'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {Text} from '../util/text/Text'
-import {useStores} from 'state/index'
-import {s} from 'lib/styles'
-import {TABS_EXPLAINER} from 'lib/assets'
-import {TABS_ENABLED} from 'lib/build-flags'
-
-const ROUTES = TABS_ENABLED
-  ? [
-      {key: 'intro', title: 'Intro'},
-      {key: 'tabs', title: 'Tabs'},
-    ]
-  : [{key: 'intro', title: 'Intro'}]
-
-const Intro = () => (
-  <View style={styles.explainer}>
-    <Text
-      style={[styles.explainerHeading, s.normal, styles.explainerHeadingIntro]}>
-      Welcome to{' '}
-      <Text style={[s.bold, s.blue3, styles.explainerHeadingBrand]}>
-        Bluesky
-      </Text>
-    </Text>
-    <Text style={[styles.explainerDesc, styles.explainerDescIntro]}>
-      This is an early beta. Your feedback is appreciated!
-    </Text>
-  </View>
-)
-
-const Tabs = () => (
-  <View style={styles.explainer}>
-    <View style={styles.explainerIcon}>
-      <View style={s.flex1} />
-      <FontAwesomeIcon
-        icon={['far', 'clone']}
-        style={[s.black as FontAwesomeIconStyle, s.mb5]}
-        size={36}
-      />
-      <View style={s.flex1} />
-    </View>
-    <Text style={styles.explainerHeading}>Tabs</Text>
-    <Text style={styles.explainerDesc}>
-      Never lose your place! Long-press to open posts and profiles in a new tab.
-    </Text>
-    <Text style={styles.explainerDesc}>
-      <Image source={TABS_EXPLAINER} style={styles.explainerImg} />
-    </Text>
-  </View>
-)
-
-const SCENE_MAP = {
-  intro: Intro,
-  tabs: Tabs,
-}
-const renderScene = SceneMap(SCENE_MAP)
-
-export const FeatureExplainer = () => {
-  const layout = useWindowDimensions()
-  const store = useStores()
-  const [index, setIndex] = useState(0)
-
-  const onPressSkip = () => store.onboard.next()
-  const onPressNext = () => {
-    if (index >= ROUTES.length - 1) {
-      store.onboard.next()
-    } else {
-      setIndex(index + 1)
-    }
-  }
-
-  const renderTabBar = (props: TabBarProps<Route>) => {
-    const inputRange = props.navigationState.routes.map((x, i) => i)
-    return (
-      <View style={styles.tabBar}>
-        <View style={s.flex1} />
-        {props.navigationState.routes.map((route, i) => {
-          const opacity = props.position.interpolate({
-            inputRange,
-            outputRange: inputRange.map(inputIndex =>
-              inputIndex === i ? 1 : 0.5,
-            ),
-          })
-
-          return (
-            <TouchableOpacity
-              key={i}
-              style={styles.tabItem}
-              onPress={() => setIndex(i)}>
-              <Animated.Text style={{opacity}}>&deg;</Animated.Text>
-            </TouchableOpacity>
-          )
-        })}
-        <View style={s.flex1} />
-      </View>
-    )
-  }
-
-  const FirstExplainer = SCENE_MAP[ROUTES[0]?.key as keyof typeof SCENE_MAP]
-  return (
-    <SafeAreaView style={styles.container}>
-      {ROUTES.length > 1 ? (
-        <TabView
-          navigationState={{index, routes: ROUTES}}
-          renderScene={renderScene}
-          renderTabBar={renderTabBar}
-          onIndexChange={setIndex}
-          initialLayout={{width: layout.width}}
-          tabBarPosition="bottom"
-        />
-      ) : FirstExplainer ? (
-        <FirstExplainer />
-      ) : (
-        <View />
-      )}
-      <View style={styles.footer}>
-        <TouchableOpacity
-          onPress={onPressSkip}
-          testID="onboardFeatureExplainerSkipBtn">
-          <Text style={[s.blue3, s.f18]}>Skip</Text>
-        </TouchableOpacity>
-        <View style={s.flex1} />
-        <TouchableOpacity
-          onPress={onPressNext}
-          testID="onboardFeatureExplainerNextBtn">
-          <Text style={[s.blue3, s.f18]}>Next</Text>
-        </TouchableOpacity>
-      </View>
-    </SafeAreaView>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-  },
-
-  tabBar: {
-    flexDirection: 'row',
-  },
-  tabItem: {
-    alignItems: 'center',
-    padding: 16,
-  },
-
-  explainer: {
-    flex: 1,
-    paddingHorizontal: 16,
-    paddingTop: 80,
-  },
-  explainerIcon: {
-    flexDirection: 'row',
-  },
-  explainerHeading: {
-    fontSize: 42,
-    fontWeight: 'bold',
-    textAlign: 'center',
-    marginBottom: 16,
-  },
-  explainerHeadingIntro: {
-    lineHeight: 60,
-    paddingTop: 50,
-    paddingBottom: 50,
-  },
-  explainerHeadingBrand: {fontSize: 56},
-  explainerDesc: {
-    fontSize: 18,
-    textAlign: 'center',
-    marginBottom: 16,
-  },
-  explainerDescIntro: {fontSize: 24},
-  explainerImg: {
-    resizeMode: 'contain',
-    maxWidth: '100%',
-    maxHeight: 330,
-  },
-
-  footer: {
-    flexDirection: 'row',
-    paddingHorizontal: 32,
-    paddingBottom: 24,
-  },
-})
diff --git a/src/view/com/onboard/FeatureExplainer.web.tsx b/src/view/com/onboard/FeatureExplainer.web.tsx
deleted file mode 100644
index 177ac58dd..000000000
--- a/src/view/com/onboard/FeatureExplainer.web.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-import React, {useState} from 'react'
-import {
-  Animated,
-  Image,
-  StyleSheet,
-  TouchableOpacity,
-  useWindowDimensions,
-  View,
-} from 'react-native'
-import {TabView, SceneMap, Route, TabBarProps} from 'react-native-tab-view'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {CenteredView} from '../util/Views.web'
-import {Text} from '../util/text/Text'
-import {useStores} from 'state/index'
-import {s, colors} from 'lib/styles'
-import {TABS_EXPLAINER} from 'lib/assets'
-import {TABS_ENABLED} from 'lib/build-flags'
-
-const ROUTES = TABS_ENABLED
-  ? [
-      {key: 'intro', title: 'Intro'},
-      {key: 'tabs', title: 'Tabs'},
-    ]
-  : [{key: 'intro', title: 'Intro'}]
-
-const Intro = () => (
-  <View style={styles.explainer}>
-    <Text
-      style={[styles.explainerHeading, s.normal, styles.explainerHeadingIntro]}>
-      Welcome to{' '}
-      <Text style={[s.bold, s.blue3, styles.explainerHeadingBrand]}>
-        Bluesky
-      </Text>
-    </Text>
-    <Text style={[styles.explainerDesc, styles.explainerDescIntro]}>
-      This is an early beta. Your feedback is appreciated!
-    </Text>
-  </View>
-)
-
-const Tabs = () => (
-  <View style={styles.explainer}>
-    <View style={styles.explainerIcon}>
-      <View style={s.flex1} />
-      <FontAwesomeIcon
-        icon={['far', 'clone']}
-        style={[s.black as FontAwesomeIconStyle, s.mb5]}
-        size={36}
-      />
-      <View style={s.flex1} />
-    </View>
-    <Text style={styles.explainerHeading}>Tabs</Text>
-    <Text style={styles.explainerDesc}>
-      Never lose your place! Long-press to open posts and profiles in a new tab.
-    </Text>
-    <Text style={styles.explainerDesc}>
-      <Image source={TABS_EXPLAINER} style={styles.explainerImg} />
-    </Text>
-  </View>
-)
-
-const SCENE_MAP = {
-  intro: Intro,
-  tabs: Tabs,
-}
-const renderScene = SceneMap(SCENE_MAP)
-
-export const FeatureExplainer = () => {
-  const layout = useWindowDimensions()
-  const store = useStores()
-  const [index, setIndex] = useState(0)
-
-  const onPressSkip = () => store.onboard.next()
-  const onPressNext = () => {
-    if (index >= ROUTES.length - 1) {
-      store.onboard.next()
-    } else {
-      setIndex(index + 1)
-    }
-  }
-
-  const renderTabBar = (props: TabBarProps<Route>) => {
-    const inputRange = props.navigationState.routes.map((x, i) => i)
-    return (
-      <View style={styles.tabBar}>
-        <View style={s.flex1} />
-        {props.navigationState.routes.map((route, i) => {
-          const opacity = props.position.interpolate({
-            inputRange,
-            outputRange: inputRange.map(inputIndex =>
-              inputIndex === i ? 1 : 0.5,
-            ),
-          })
-
-          return (
-            <TouchableOpacity
-              key={i}
-              style={styles.tabItem}
-              onPress={() => setIndex(i)}>
-              <Animated.Text style={{opacity}}>&deg;</Animated.Text>
-            </TouchableOpacity>
-          )
-        })}
-        <View style={s.flex1} />
-      </View>
-    )
-  }
-
-  const FirstExplainer = SCENE_MAP[ROUTES[0]?.key as keyof typeof SCENE_MAP]
-  return (
-    <CenteredView style={styles.container}>
-      {ROUTES.length > 1 ? (
-        <TabView
-          navigationState={{index, routes: ROUTES}}
-          renderScene={renderScene}
-          renderTabBar={renderTabBar}
-          onIndexChange={setIndex}
-          initialLayout={{width: layout.width}}
-          tabBarPosition="bottom"
-        />
-      ) : FirstExplainer ? (
-        <FirstExplainer />
-      ) : (
-        <View />
-      )}
-      <View style={styles.footer}>
-        <TouchableOpacity onPress={onPressSkip}>
-          <Text style={styles.footerBtn}>Skip</Text>
-        </TouchableOpacity>
-        <TouchableOpacity onPress={onPressNext}>
-          <Text style={[styles.footerBtn, styles.footerBtnNext]}>Next</Text>
-        </TouchableOpacity>
-      </View>
-    </CenteredView>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    height: '100%',
-    justifyContent: 'center',
-    paddingBottom: '10%',
-  },
-
-  tabBar: {
-    flexDirection: 'row',
-  },
-  tabItem: {
-    alignItems: 'center',
-    padding: 16,
-  },
-
-  explainer: {
-    paddingHorizontal: 16,
-  },
-  explainerIcon: {
-    flexDirection: 'row',
-  },
-  explainerHeading: {
-    fontSize: 42,
-    fontWeight: 'bold',
-    textAlign: 'center',
-    marginBottom: 16,
-  },
-  explainerHeadingIntro: {
-    lineHeight: 40,
-  },
-  explainerHeadingBrand: {fontSize: 56},
-  explainerDesc: {
-    fontSize: 18,
-    textAlign: 'center',
-    marginBottom: 16,
-    color: colors.gray5,
-  },
-  explainerDescIntro: {fontSize: 24},
-  explainerImg: {
-    resizeMode: 'contain',
-    maxWidth: '100%',
-    maxHeight: 330,
-  },
-
-  footer: {
-    flexDirection: 'row',
-    justifyContent: 'center',
-    paddingTop: 24,
-  },
-  footerBtn: {
-    color: colors.blue3,
-    fontSize: 19,
-    paddingHorizontal: 36,
-    paddingVertical: 8,
-  },
-  footerBtnNext: {
-    marginLeft: 10,
-    borderWidth: 1,
-    borderColor: colors.blue3,
-    borderRadius: 6,
-  },
-})
diff --git a/src/view/com/onboard/Follows.tsx b/src/view/com/onboard/Follows.tsx
deleted file mode 100644
index e7de82b39..000000000
--- a/src/view/com/onboard/Follows.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react'
-import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {SuggestedFollows} from '../discover/SuggestedFollows'
-import {Text} from '../util/text/Text'
-import {useStores} from 'state/index'
-import {s} from 'lib/styles'
-
-export const Follows = observer(() => {
-  const store = useStores()
-
-  const onNoSuggestions = () => {
-    // no suggestions, bounce from this view
-    store.onboard.next()
-  }
-  const onPressNext = () => store.onboard.next()
-
-  return (
-    <SafeAreaView style={styles.container}>
-      <Text style={styles.title}>Suggested follows</Text>
-      <View style={s.flex1}>
-        <SuggestedFollows onNoSuggestions={onNoSuggestions} />
-      </View>
-      <View style={styles.footer}>
-        <TouchableOpacity onPress={onPressNext} testID="onboardFollowsSkipBtn">
-          <Text style={[s.blue3, s.f18]}>Skip</Text>
-        </TouchableOpacity>
-        <View style={s.flex1} />
-        <TouchableOpacity onPress={onPressNext} testID="onboardFollowsNextBtn">
-          <Text style={[s.blue3, s.f18]}>Next</Text>
-        </TouchableOpacity>
-      </View>
-    </SafeAreaView>
-  )
-})
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-  },
-
-  title: {
-    fontSize: 24,
-    fontWeight: 'bold',
-    paddingHorizontal: 16,
-    paddingBottom: 12,
-  },
-
-  footer: {
-    flexDirection: 'row',
-    paddingHorizontal: 32,
-    paddingBottom: 24,
-    paddingTop: 16,
-  },
-})
diff --git a/src/view/com/onboard/Follows.web.tsx b/src/view/com/onboard/Follows.web.tsx
deleted file mode 100644
index 6b015bb09..000000000
--- a/src/view/com/onboard/Follows.web.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import React from 'react'
-import {SafeAreaView, StyleSheet, TouchableOpacity} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {SuggestedFollows} from '../discover/SuggestedFollows'
-import {CenteredView} from '../util/Views.web'
-import {Text} from '../util/text/Text'
-import {useStores} from 'state/index'
-import {s} from 'lib/styles'
-
-export const Follows = observer(() => {
-  const store = useStores()
-
-  const onNoSuggestions = () => {
-    // no suggestions, bounce from this view
-    store.onboard.next()
-  }
-  const onPressNext = () => store.onboard.next()
-
-  return (
-    <SafeAreaView style={styles.container}>
-      <CenteredView style={styles.header}>
-        <Text type="title-lg">
-          Follow these people to see their posts in your feed
-        </Text>
-        <TouchableOpacity onPress={onPressNext}>
-          <Text style={[styles.title, s.blue3, s.pr10]}>Next &raquo;</Text>
-        </TouchableOpacity>
-      </CenteredView>
-      <SuggestedFollows onNoSuggestions={onNoSuggestions} />
-    </SafeAreaView>
-  )
-})
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-  },
-  title: {
-    fontSize: 24,
-    fontWeight: 'bold',
-  },
-
-  header: {
-    paddingTop: 30,
-    paddingBottom: 40,
-  },
-})
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 98d44267d..65bae0192 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -305,6 +305,8 @@ export const PostThreadItem = observer(function PostThreadItem({
                 authorHandle={item.post.author.handle}
                 authorDisplayName={item.post.author.displayName}
                 timestamp={item.post.indexedAt}
+                did={item.post.author.did}
+                declarationCid={item.post.author.declaration.cid}
               />
               {item.post.author.viewer?.muted ? (
                 <View style={[styles.mutedWarning, pal.btn]}>
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 1550f8620..c0ff95416 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -156,6 +156,8 @@ export const Post = observer(function Post({
             authorHandle={item.post.author.handle}
             authorDisplayName={item.post.author.displayName}
             timestamp={item.post.indexedAt}
+            did={item.post.author.did}
+            declarationCid={item.post.author.declaration.cid}
           />
           {replyAuthorDid !== '' && (
             <View style={[s.flexRow, s.mb2, s.alignCenter]}>
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 7e5d166d2..03a719f16 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -13,16 +13,21 @@ import {EmptyState} from '../util/EmptyState'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {FeedModel} from 'state/models/feed-view'
 import {FeedItem} from './FeedItem'
+import {WelcomeBanner} from '../util/WelcomeBanner'
 import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
 import {s} from 'lib/styles'
 import {useAnalytics} from 'lib/analytics'
+import {useStores} from 'state/index'
 
 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
 const ERROR_FEED_ITEM = {_reactKey: '__error__'}
+const WELCOME_FEED_ITEM = {_reactKey: '__welcome__'}
 
 export const Feed = observer(function Feed({
   feed,
   style,
+  showWelcomeBanner,
+  showPostFollowBtn,
   scrollElRef,
   onPressTryAgain,
   onScroll,
@@ -31,6 +36,8 @@ export const Feed = observer(function Feed({
 }: {
   feed: FeedModel
   style?: StyleProp<ViewStyle>
+  showWelcomeBanner?: boolean
+  showPostFollowBtn?: boolean
   scrollElRef?: MutableRefObject<FlatList<any> | null>
   onPressTryAgain?: () => void
   onScroll?: OnScrollCb
@@ -38,7 +45,9 @@ export const Feed = observer(function Feed({
   headerOffset?: number
 }) {
   const {track} = useAnalytics()
+  const store = useStores()
   const [isRefreshing, setIsRefreshing] = React.useState(false)
+  const [isNewUser, setIsNewUser] = React.useState<boolean>(false)
 
   const data = React.useMemo(() => {
     let feedItems: any[] = []
@@ -46,6 +55,9 @@ export const Feed = observer(function Feed({
       if (feed.hasError) {
         feedItems = feedItems.concat([ERROR_FEED_ITEM])
       }
+      if (showWelcomeBanner && isNewUser) {
+        feedItems = feedItems.concat([WELCOME_FEED_ITEM])
+      }
       if (feed.isEmpty) {
         feedItems = feedItems.concat([EMPTY_FEED_ITEM])
       } else {
@@ -53,21 +65,39 @@ export const Feed = observer(function Feed({
       }
     }
     return feedItems
-  }, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.feed])
+  }, [
+    feed.hasError,
+    feed.hasLoaded,
+    feed.isEmpty,
+    feed.feed,
+    showWelcomeBanner,
+    isNewUser,
+  ])
 
   // events
   // =
 
+  const checkWelcome = React.useCallback(async () => {
+    if (showWelcomeBanner) {
+      await store.me.follows.fetchIfNeeded()
+      setIsNewUser(store.me.follows.isEmpty)
+    }
+  }, [showWelcomeBanner, store.me.follows])
+  React.useEffect(() => {
+    checkWelcome()
+  }, [checkWelcome])
+
   const onRefresh = React.useCallback(async () => {
     track('Feed:onRefresh')
     setIsRefreshing(true)
+    checkWelcome()
     try {
       await feed.refresh()
     } catch (err) {
       feed.rootStore.log.error('Failed to refresh posts feed', err)
     }
     setIsRefreshing(false)
-  }, [feed, track, setIsRefreshing])
+  }, [feed, track, setIsRefreshing, checkWelcome])
   const onEndReached = React.useCallback(async () => {
     track('Feed:onEndReached')
     try {
@@ -101,10 +131,12 @@ export const Feed = observer(function Feed({
             onPressTryAgain={onPressTryAgain}
           />
         )
+      } else if (item === WELCOME_FEED_ITEM) {
+        return <WelcomeBanner />
       }
-      return <FeedItem item={item} />
+      return <FeedItem item={item} showFollowBtn={showPostFollowBtn} />
     },
-    [feed, onPressTryAgain],
+    [feed, onPressTryAgain, showPostFollowBtn],
   )
 
   const FeedFooter = React.useCallback(
@@ -123,6 +155,7 @@ export const Feed = observer(function Feed({
     <View testID={testID} style={style}>
       {feed.isLoading && data.length === 0 && (
         <CenteredView style={{paddingTop: headerOffset}}>
+          {showWelcomeBanner && isNewUser && <WelcomeBanner />}
           <PostFeedLoadingPlaceholder />
         </CenteredView>
       )}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 474afb55b..c3e9f61fa 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -26,10 +26,12 @@ import {useAnalytics} from 'lib/analytics'
 export const FeedItem = observer(function ({
   item,
   showReplyLine,
+  showFollowBtn,
   ignoreMuteFor,
 }: {
   item: FeedItemModel
   showReplyLine?: boolean
+  showFollowBtn?: boolean
   ignoreMuteFor?: string
 }) {
   const store = useStores()
@@ -175,6 +177,9 @@ export const FeedItem = observer(function ({
               authorHandle={item.post.author.handle}
               authorDisplayName={item.post.author.displayName}
               timestamp={item.post.indexedAt}
+              did={item.post.author.did}
+              declarationCid={item.post.author.declaration.cid}
+              showFollowBtn={showFollowBtn}
             />
             {!isChild && replyAuthorDid !== '' && (
               <View style={[s.flexRow, s.mb2, s.alignCenter]}>
diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx
new file mode 100644
index 000000000..71462bea8
--- /dev/null
+++ b/src/view/com/profile/FollowButton.tsx
@@ -0,0 +1,57 @@
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {Text} from '../util/text/Text'
+import {useStores} from 'state/index'
+import * as apilib from 'lib/api/index'
+import * as Toast from '../util/Toast'
+import {usePalette} from 'lib/hooks/usePalette'
+
+const FollowButton = observer(
+  ({did, declarationCid}: {did: string; declarationCid: string}) => {
+    const store = useStores()
+    const pal = usePalette('default')
+    const isFollowing = store.me.follows.isFollowing(did)
+
+    const onToggleFollow = async () => {
+      if (store.me.follows.isFollowing(did)) {
+        try {
+          await apilib.unfollow(store, store.me.follows.getFollowUri(did))
+          store.me.follows.removeFollow(did)
+        } catch (e: any) {
+          store.log.error('Failed fo delete follow', e)
+          Toast.show('An issue occurred, please try again.')
+        }
+      } else {
+        try {
+          const res = await apilib.follow(store, did, declarationCid)
+          store.me.follows.addFollow(did, res.uri)
+        } catch (e: any) {
+          store.log.error('Failed fo create follow', e)
+          Toast.show('An issue occurred, please try again.')
+        }
+      }
+    }
+
+    return (
+      <TouchableOpacity onPress={onToggleFollow}>
+        <View style={[styles.btn, pal.btn]}>
+          <Text type="button" style={[pal.text]}>
+            {isFollowing ? 'Unfollow' : 'Follow'}
+          </Text>
+        </View>
+      </TouchableOpacity>
+    )
+  },
+)
+
+export default FollowButton
+
+const styles = StyleSheet.create({
+  btn: {
+    paddingVertical: 7,
+    borderRadius: 50,
+    marginLeft: 6,
+    paddingHorizontal: 14,
+  },
+})
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 6a136a02d..3c487b70f 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -1,14 +1,13 @@
 import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
-import * as Toast from '../util/Toast'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useStores} from 'state/index'
-import * as apilib from 'lib/api/index'
+import FollowButton from './FollowButton'
 
 export function ProfileCard({
   handle,
@@ -102,26 +101,7 @@ export const ProfileCardWithFollowBtn = observer(
   }) => {
     const store = useStores()
     const isMe = store.me.handle === handle
-    const isFollowing = store.me.follows.isFollowing(did)
-    const onToggleFollow = async () => {
-      if (store.me.follows.isFollowing(did)) {
-        try {
-          await apilib.unfollow(store, store.me.follows.getFollowUri(did))
-          store.me.follows.removeFollow(did)
-        } catch (e: any) {
-          store.log.error('Failed fo delete follow', e)
-          Toast.show('An issue occurred, please try again.')
-        }
-      } else {
-        try {
-          const res = await apilib.follow(store, did, declarationCid)
-          store.me.follows.addFollow(did, res.uri)
-        } catch (e: any) {
-          store.log.error('Failed fo create follow', e)
-          Toast.show('An issue occurred, please try again.')
-        }
-      }
-    }
+
     return (
       <ProfileCard
         handle={handle}
@@ -132,34 +112,13 @@ export const ProfileCardWithFollowBtn = observer(
         renderButton={
           isMe
             ? undefined
-            : () => (
-                <FollowBtn isFollowing={isFollowing} onPress={onToggleFollow} />
-              )
+            : () => <FollowButton did={did} declarationCid={declarationCid} />
         }
       />
     )
   },
 )
 
-function FollowBtn({
-  isFollowing,
-  onPress,
-}: {
-  isFollowing: boolean
-  onPress: () => void
-}) {
-  const pal = usePalette('default')
-  return (
-    <TouchableOpacity onPress={onPress}>
-      <View style={[styles.btn, pal.btn]}>
-        <Text type="button" style={[pal.text]}>
-          {isFollowing ? 'Unfollow' : 'Follow'}
-        </Text>
-      </View>
-    </TouchableOpacity>
-  )
-}
-
 const styles = StyleSheet.create({
   outer: {
     borderTopWidth: 1,
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index 6ba6fac1b..a07d91899 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -1,37 +1,74 @@
 import React from 'react'
-import {Platform, StyleSheet, View} from 'react-native'
+import {StyleSheet, View} from 'react-native'
 import {Text} from './text/Text'
 import {ago} from 'lib/strings/time'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {observer} from 'mobx-react-lite'
+import FollowButton from '../profile/FollowButton'
 
 interface PostMetaOpts {
   authorHandle: string
   authorDisplayName: string | undefined
   timestamp: string
+  did: string
+  declarationCid: string
+  showFollowBtn?: boolean
 }
 
-export function PostMeta(opts: PostMetaOpts) {
+export const PostMeta = observer(function (opts: PostMetaOpts) {
   const pal = usePalette('default')
   let displayName = opts.authorDisplayName || opts.authorHandle
   let handle = opts.authorHandle
+  const store = useStores()
+  const isMe = opts.did === store.me.did
 
-  // HACK
-  // Android simply cannot handle the truncation case we need
-  // so we have to do it manually here
-  // -prf
-  if (Platform.OS === 'android') {
-    if (displayName.length + handle.length > 26) {
-      if (displayName.length > 26) {
-        displayName = displayName.slice(0, 23) + '...'
-      } else {
-        handle = handle.slice(0, 23 - displayName.length) + '...'
-        if (handle.endsWith('....')) {
-          handle = handle.slice(0, -4) + '...'
-        }
-      }
-    }
+  // NOTE we capture `isFollowing` via a memo so that follows
+  //      don't change this UI immediately, but rather upon future
+  //      renders
+  const isFollowing = React.useMemo(
+    () => store.me.follows.isFollowing(opts.did),
+    [opts.did, store.me.follows],
+  )
+
+  if (opts.showFollowBtn && !isMe && !isFollowing) {
+    // two-liner with follow button
+    return (
+      <View style={[styles.metaTwoLine]}>
+        <View>
+          <Text
+            type="lg-bold"
+            style={[pal.text]}
+            numberOfLines={1}
+            lineHeight={1.2}>
+            {displayName}{' '}
+            <Text
+              type="md"
+              style={[styles.metaItem, pal.textLight]}
+              lineHeight={1.2}>
+              &middot; {ago(opts.timestamp)}
+            </Text>
+          </Text>
+          <Text
+            type="md"
+            style={[styles.metaItem, pal.textLight]}
+            lineHeight={1.2}>
+            {handle ? (
+              <Text type="md" style={[pal.textLight]}>
+                @{handle}
+              </Text>
+            ) : undefined}
+          </Text>
+        </View>
+
+        <View>
+          <FollowButton did={opts.did} declarationCid={opts.declarationCid} />
+        </View>
+      </View>
+    )
   }
 
+  // one-liner
   return (
     <View style={styles.meta}>
       <View style={[styles.metaItem, styles.maxWidth]}>
@@ -53,13 +90,18 @@ export function PostMeta(opts: PostMetaOpts) {
       </Text>
     </View>
   )
-}
+})
 
 const styles = StyleSheet.create({
   meta: {
     flexDirection: 'row',
     alignItems: 'baseline',
-    paddingTop: 0,
+    paddingBottom: 2,
+  },
+  metaTwoLine: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'space-between',
     paddingBottom: 2,
   },
   metaItem: {
diff --git a/src/view/com/util/WelcomeBanner.tsx b/src/view/com/util/WelcomeBanner.tsx
new file mode 100644
index 000000000..d52288502
--- /dev/null
+++ b/src/view/com/util/WelcomeBanner.tsx
@@ -0,0 +1,33 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {usePalette} from 'lib/hooks/usePalette'
+import {Text} from './text/Text'
+import {s} from 'lib/styles'
+
+export function WelcomeBanner() {
+  const pal = usePalette('default')
+  return (
+    <View
+      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 the private beta!
+      </Text>
+      <Text type="lg" style={[pal.text, s.textCenter]}>
+        Here are some recent posts. Follow their creators to build your feed.
+      </Text>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    paddingTop: 30,
+    paddingBottom: 26,
+    paddingHorizontal: 20,
+    borderTopWidth: 1,
+  },
+})
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index d11a9fb72..5b5699bcc 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -71,8 +71,6 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
     store.log.debug('HomeScreen: Updating feed')
     if (store.me.mainFeed.hasContent) {
       store.me.mainFeed.update()
-    } else {
-      store.me.mainFeed.setup()
     }
     return cleanup
   }, [visible, store, store.me.mainFeed, navIdx, doPoll, wasVisible, scrollToTop, screen])
@@ -97,6 +95,8 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
         feed={store.me.mainFeed}
         scrollElRef={scrollElRef}
         style={s.hContentRegion}
+        showWelcomeBanner
+        showPostFollowBtn
         onPressTryAgain={onPressTryAgain}
         onScroll={onMainScroll}
         headerOffset={HEADER_HEIGHT}
diff --git a/src/view/screens/Onboard.tsx b/src/view/screens/Onboard.tsx
deleted file mode 100644
index 1485670e7..000000000
--- a/src/view/screens/Onboard.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import React, {useEffect} from 'react'
-import {StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {FeatureExplainer} from '../com/onboard/FeatureExplainer'
-import {Follows} from '../com/onboard/Follows'
-import {OnboardStage, OnboardStageOrder} from 'state/models/onboard'
-import {useStores} from 'state/index'
-
-export const Onboard = observer(() => {
-  const store = useStores()
-
-  useEffect(() => {
-    // sanity check - bounce out of onboarding if the stage is wrong somehow
-    if (!OnboardStageOrder.includes(store.onboard.stage)) {
-      store.onboard.stop()
-    }
-  }, [store.onboard])
-
-  let Com
-  if (store.onboard.stage === OnboardStage.Explainers) {
-    Com = FeatureExplainer
-  } else if (store.onboard.stage === OnboardStage.Follows) {
-    Com = Follows
-  } else {
-    Com = View
-  }
-
-  return (
-    <View style={styles.container}>
-      <Com />
-    </View>
-  )
-})
-
-const styles = StyleSheet.create({
-  container: {
-    height: '100%',
-    backgroundColor: '#fff',
-  },
-})
diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx
index dbfcad0ee..80403a6de 100644
--- a/src/view/shell/mobile/index.tsx
+++ b/src/view/shell/mobile/index.tsx
@@ -26,7 +26,6 @@ import {
 import {match, MatchResult} from '../../routes'
 import {Login} from '../../screens/Login'
 import {Menu} from './Menu'
-import {Onboard} from '../../screens/Onboard'
 import {HorzSwipe} from '../../com/util/gestures/HorzSwipe'
 import {ModalsContainer} from '../../com/modals/Modal'
 import {Lightbox} from '../../com/lightbox/Lightbox'
@@ -408,17 +407,6 @@ export const MobileShell: React.FC = observer(() => {
       </View>
     )
   }
-  if (store.onboard.isOnboarding) {
-    return (
-      <View testID="onboardOuterView" style={styles.outerContainer}>
-        <View style={styles.innerContainer}>
-          <ErrorBoundary>
-            <Onboard />
-          </ErrorBoundary>
-        </View>
-      </View>
-    )
-  }
 
   const isAtHome =
     store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default]
diff --git a/src/view/shell/web/index.tsx b/src/view/shell/web/index.tsx
index 76b5ed09f..a76ae8060 100644
--- a/src/view/shell/web/index.tsx
+++ b/src/view/shell/web/index.tsx
@@ -6,7 +6,6 @@ import {useStores} from 'state/index'
 import {NavigationModel} from 'state/models/navigation'
 import {match, MatchResult} from '../../routes'
 import {DesktopHeader} from './DesktopHeader'
-import {Onboard} from '../../screens/Onboard'
 import {Login} from '../../screens/Login'
 import {ErrorBoundary} from '../../com/util/ErrorBoundary'
 import {Lightbox} from '../../com/lightbox/Lightbox'
@@ -35,15 +34,6 @@ export const WebShell: React.FC = observer(() => {
       </View>
     )
   }
-  if (store.onboard.isOnboarding) {
-    return (
-      <View style={styles.outerContainer}>
-        <ErrorBoundary>
-          <Onboard />
-        </ErrorBoundary>
-      </View>
-    )
-  }
 
   return (
     <View style={[styles.outerContainer, pageBg]}>