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>
This commit is contained in:
parent
9b46b2e6a9
commit
bd9386d81c
31 changed files with 426 additions and 866 deletions
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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}}>°</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,
|
||||
},
|
||||
})
|
|
@ -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}}>°</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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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 »</Text>
|
||||
</TouchableOpacity>
|
||||
</CenteredView>
|
||||
<SuggestedFollows onNoSuggestions={onNoSuggestions} />
|
||||
</SafeAreaView>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
|
||||
header: {
|
||||
paddingTop: 30,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
})
|
|
@ -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]}>
|
||||
|
|
|
@ -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]}>
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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]}>
|
||||
|
|
57
src/view/com/profile/FollowButton.tsx
Normal file
57
src/view/com/profile/FollowButton.tsx
Normal 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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
|
|
|
@ -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}>
|
||||
· {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: {
|
||||
|
|
33
src/view/com/util/WelcomeBanner.tsx
Normal file
33
src/view/com/util/WelcomeBanner.tsx
Normal 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,
|
||||
},
|
||||
})
|
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
})
|
|
@ -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]
|
||||
|
|
|
@ -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]}>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue