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>
zio/stable
Ansh 2023-03-02 10:21:33 -08:00 committed by GitHub
parent 9b46b2e6a9
commit bd9386d81c
31 changed files with 426 additions and 866 deletions

View File

@ -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)
})
})

View File

@ -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()
})
})

View File

@ -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

View File

@ -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,
}

View File

@ -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

View File

@ -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'},

View File

@ -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,
)

View File

@ -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)

View File

@ -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) {

View File

@ -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]
}
}
}

View File

@ -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)
}

View File

@ -345,7 +345,6 @@ export class SessionModel {
)
this.setActiveSession(agent, did)
this.rootStore.onboard.start()
this.rootStore.log.debug('SessionModel:createAccount succeeded')
}

View File

@ -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`)

View File

@ -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
}

View File

@ -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>
</>

View File

@ -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,
},
})

View File

@ -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,
},
})

View File

@ -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,
},
})

View File

@ -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,
},
})

View File

@ -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]}>

View File

@ -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]}>

View File

@ -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>
)}

View File

@ -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]}>

View File

@ -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,
},
})

View File

@ -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,

View File

@ -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: {

View File

@ -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,
},
})

View File

@ -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}

View File

@ -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',
},
})

View File

@ -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]

View File

@ -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]}>