Onboarding tweaks (#272)
* Small fix to side menu rendering * Change onboarding to use an explicit 'is onboarding' mode to more clearly control the flow * Add a progress bar to the welcome banner * Dont show the 'unfollow button' on posts in weird times (close #271) * Improve the empty state of the feed * Only suggest recent posts
This commit is contained in:
parent
74c30c60b8
commit
36791e68b3
13 changed files with 259 additions and 123 deletions
|
@ -7,26 +7,28 @@ import {
|
|||
StyleSheet,
|
||||
ViewStyle,
|
||||
} from 'react-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 {EmptyState} from '../util/EmptyState'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {Button} from '../util/forms/Button'
|
||||
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'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {MagnifyingGlassIcon} from 'lib/icons'
|
||||
|
||||
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,
|
||||
|
@ -36,7 +38,6 @@ export const Feed = observer(function Feed({
|
|||
}: {
|
||||
feed: FeedModel
|
||||
style?: StyleProp<ViewStyle>
|
||||
showWelcomeBanner?: boolean
|
||||
showPostFollowBtn?: boolean
|
||||
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||
onPressTryAgain?: () => void
|
||||
|
@ -44,10 +45,11 @@ export const Feed = observer(function Feed({
|
|||
testID?: string
|
||||
headerOffset?: number
|
||||
}) {
|
||||
const {track} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const palInverted = usePalette('inverted')
|
||||
const store = useStores()
|
||||
const {track} = useAnalytics()
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||
const [isNewUser, setIsNewUser] = React.useState<boolean>(false)
|
||||
|
||||
const data = React.useMemo(() => {
|
||||
let feedItems: any[] = []
|
||||
|
@ -55,9 +57,6 @@ 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 {
|
||||
|
@ -65,39 +64,21 @@ export const Feed = observer(function Feed({
|
|||
}
|
||||
}
|
||||
return feedItems
|
||||
}, [
|
||||
feed.hasError,
|
||||
feed.hasLoaded,
|
||||
feed.isEmpty,
|
||||
feed.nonReplyFeed,
|
||||
showWelcomeBanner,
|
||||
isNewUser,
|
||||
])
|
||||
}, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.nonReplyFeed])
|
||||
|
||||
// events
|
||||
// =
|
||||
|
||||
const checkWelcome = React.useCallback(async () => {
|
||||
if (showWelcomeBanner && store.me.did) {
|
||||
await store.me.follows.fetchIfNeeded()
|
||||
setIsNewUser(store.me.follows.isEmpty)
|
||||
}
|
||||
}, [showWelcomeBanner, store.me.follows, store.me.did])
|
||||
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, checkWelcome])
|
||||
}, [feed, track, setIsRefreshing])
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
track('Feed:onEndReached')
|
||||
try {
|
||||
|
@ -118,11 +99,30 @@ export const Feed = observer(function Feed({
|
|||
({item}: {item: any}) => {
|
||||
if (item === EMPTY_FEED_ITEM) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="bars"
|
||||
message="This feed is empty!"
|
||||
style={styles.emptyState}
|
||||
/>
|
||||
<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={() => store.nav.navigate('/search')}>
|
||||
<Text type="lg-medium" style={palInverted.text}>
|
||||
Find accounts
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="angle-right"
|
||||
style={palInverted.text as FontAwesomeIconStyle}
|
||||
size={14}
|
||||
/>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
} else if (item === ERROR_FEED_ITEM) {
|
||||
return (
|
||||
|
@ -131,12 +131,10 @@ export const Feed = observer(function Feed({
|
|||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
)
|
||||
} else if (item === WELCOME_FEED_ITEM) {
|
||||
return <WelcomeBanner />
|
||||
}
|
||||
return <FeedItem item={item} showFollowBtn={showPostFollowBtn} />
|
||||
},
|
||||
[feed, onPressTryAgain, showPostFollowBtn],
|
||||
[feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, store.nav],
|
||||
)
|
||||
|
||||
const FeedFooter = React.useCallback(
|
||||
|
@ -155,7 +153,6 @@ 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>
|
||||
)}
|
||||
|
@ -184,5 +181,21 @@ export const Feed = observer(function Feed({
|
|||
|
||||
const styles = StyleSheet.create({
|
||||
feedFooter: {paddingTop: 20},
|
||||
emptyState: {paddingVertical: 40},
|
||||
emptyContainer: {
|
||||
paddingVertical: 40,
|
||||
paddingHorizontal: 30,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyIcon: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
emptyBtn: {
|
||||
marginTop: 20,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
})
|
||||
|
|
|
@ -1,23 +1,29 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {Button} from '../util/forms/Button'
|
||||
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}) => {
|
||||
({
|
||||
did,
|
||||
declarationCid,
|
||||
onToggleFollow,
|
||||
}: {
|
||||
did: string
|
||||
declarationCid: string
|
||||
onToggleFollow?: (v: boolean) => void
|
||||
}) => {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const isFollowing = store.me.follows.isFollowing(did)
|
||||
|
||||
const onToggleFollow = async () => {
|
||||
const onToggleFollowInner = async () => {
|
||||
if (store.me.follows.isFollowing(did)) {
|
||||
try {
|
||||
await apilib.unfollow(store, store.me.follows.getFollowUri(did))
|
||||
store.me.follows.removeFollow(did)
|
||||
onToggleFollow?.(false)
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed fo delete follow', e)
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
|
@ -26,6 +32,7 @@ const FollowButton = observer(
|
|||
try {
|
||||
const res = await apilib.follow(store, did, declarationCid)
|
||||
store.me.follows.addFollow(did, res.uri)
|
||||
onToggleFollow?.(true)
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed fo create follow', e)
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
|
@ -34,24 +41,13 @@ const FollowButton = observer(
|
|||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onToggleFollow}>
|
||||
<View style={[styles.btn, pal.btn]}>
|
||||
<Text type="button" style={[pal.text]}>
|
||||
{isFollowing ? 'Unfollow' : 'Follow'}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Button
|
||||
type={isFollowing ? 'default' : 'primary'}
|
||||
onPress={onToggleFollowInner}
|
||||
label={isFollowing ? 'Unfollow' : 'Follow'}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default FollowButton
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
btn: {
|
||||
paddingVertical: 7,
|
||||
borderRadius: 50,
|
||||
marginLeft: 6,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -24,20 +24,18 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
|
|||
let handle = opts.authorHandle
|
||||
const store = useStores()
|
||||
const isMe = opts.did === store.me.did
|
||||
const isFollowing =
|
||||
typeof opts.did === 'string' && store.me.follows.isFollowing(opts.did)
|
||||
|
||||
// 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(
|
||||
() =>
|
||||
typeof opts.did === 'string' && store.me.follows.isFollowing(opts.did),
|
||||
[opts.did, store.me.follows],
|
||||
)
|
||||
const [didFollow, setDidFollow] = React.useState(false)
|
||||
const onToggleFollow = React.useCallback(() => {
|
||||
setDidFollow(true)
|
||||
}, [setDidFollow])
|
||||
|
||||
if (
|
||||
opts.showFollowBtn &&
|
||||
!isMe &&
|
||||
!isFollowing &&
|
||||
(!isFollowing || didFollow) &&
|
||||
opts.did &&
|
||||
opts.declarationCid
|
||||
) {
|
||||
|
@ -71,7 +69,11 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
|
|||
</View>
|
||||
|
||||
<View>
|
||||
<FollowButton did={opts.did} declarationCid={opts.declarationCid} />
|
||||
<FollowButton
|
||||
did={opts.did}
|
||||
declarationCid={opts.declarationCid}
|
||||
onToggleFollow={onToggleFollow}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
|
|
@ -1,11 +1,43 @@
|
|||
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'
|
||||
|
||||
export function WelcomeBanner() {
|
||||
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 (
|
||||
<View
|
||||
testID="welcomeBanner"
|
||||
|
@ -16,18 +48,53 @@ export function WelcomeBanner() {
|
|||
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>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingTop: 30,
|
||||
paddingBottom: 26,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 16,
|
||||
paddingHorizontal: 20,
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
controls: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 10,
|
||||
},
|
||||
progress: {
|
||||
marginTop: 12,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -13,6 +13,7 @@ import {choose} from 'lib/functions'
|
|||
export type ButtonType =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'default'
|
||||
| 'inverted'
|
||||
| 'primary-outline'
|
||||
| 'secondary-outline'
|
||||
|
@ -40,6 +41,9 @@ export function Button({
|
|||
secondary: {
|
||||
backgroundColor: theme.palette.secondary.background,
|
||||
},
|
||||
default: {
|
||||
backgroundColor: theme.palette.default.backgroundLight,
|
||||
},
|
||||
inverted: {
|
||||
backgroundColor: theme.palette.inverted.background,
|
||||
},
|
||||
|
@ -66,15 +70,18 @@ export function Button({
|
|||
const labelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, {
|
||||
primary: {
|
||||
color: theme.palette.primary.text,
|
||||
fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined,
|
||||
fontWeight: '600',
|
||||
},
|
||||
secondary: {
|
||||
color: theme.palette.secondary.text,
|
||||
fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined,
|
||||
},
|
||||
default: {
|
||||
color: theme.palette.default.text,
|
||||
},
|
||||
inverted: {
|
||||
color: theme.palette.inverted.text,
|
||||
fontWeight: theme.palette.inverted.isLowContrast ? '500' : undefined,
|
||||
fontWeight: '600',
|
||||
},
|
||||
'primary-outline': {
|
||||
color: theme.palette.primary.textInverted,
|
||||
|
@ -114,7 +121,8 @@ export function Button({
|
|||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
paddingHorizontal: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 24,
|
||||
},
|
||||
})
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue