about summary refs log tree commit diff
path: root/src/view/com
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/view/com
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/view/com')
-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
11 files changed, 589 insertions, 273 deletions
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>
+  )
+}