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 postszio/stable
parent
74c30c60b8
commit
36791e68b3
|
@ -37,13 +37,20 @@ function mergePosts(
|
||||||
// filter the feed down to the post with the most upvotes
|
// filter the feed down to the post with the most upvotes
|
||||||
res.data.feed = res.data.feed.reduce(
|
res.data.feed = res.data.feed.reduce(
|
||||||
(acc: AppBskyFeedFeedViewPost.Main[], v) => {
|
(acc: AppBskyFeedFeedViewPost.Main[], v) => {
|
||||||
if (!acc?.[0] && !v.reason) {
|
if (
|
||||||
|
!acc?.[0] &&
|
||||||
|
!v.reason &&
|
||||||
|
!v.reply &&
|
||||||
|
isRecentEnough(v.post.indexedAt)
|
||||||
|
) {
|
||||||
return [v]
|
return [v]
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
acc &&
|
acc &&
|
||||||
!v.reason &&
|
!v.reason &&
|
||||||
v.post.upvoteCount > acc[0].post.upvoteCount
|
!v.reply &&
|
||||||
|
v.post.upvoteCount > acc[0]?.post.upvoteCount &&
|
||||||
|
isRecentEnough(v.post.indexedAt)
|
||||||
) {
|
) {
|
||||||
return [v]
|
return [v]
|
||||||
}
|
}
|
||||||
|
@ -112,6 +119,16 @@ function isCombinedCursor(cursor: string) {
|
||||||
return cursor.includes(',')
|
return cursor.includes(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TWO_DAYS_AGO = Date.now() - 1e3 * 60 * 60 * 48
|
||||||
|
function isRecentEnough(date: string) {
|
||||||
|
try {
|
||||||
|
const d = Number(new Date(date))
|
||||||
|
return d > TWO_DAYS_AGO
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getMultipleAuthorsPosts,
|
getMultipleAuthorsPosts,
|
||||||
mergePosts,
|
mergePosts,
|
||||||
|
|
|
@ -212,7 +212,7 @@ export class FeedModel {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public rootStore: RootStoreModel,
|
public rootStore: RootStoreModel,
|
||||||
public feedType: 'home' | 'author',
|
public feedType: 'home' | 'author' | 'suggested',
|
||||||
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams,
|
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams,
|
||||||
) {
|
) {
|
||||||
makeAutoObservable(
|
makeAutoObservable(
|
||||||
|
@ -256,7 +256,7 @@ export class FeedModel {
|
||||||
item.reply?.root.author.did === item.post.author.did)
|
item.reply?.root.author.did === item.post.author.did)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
} else {
|
} else if (this.feedType === 'home') {
|
||||||
return this.feed.filter(item => {
|
return this.feed.filter(item => {
|
||||||
const isRepost = Boolean(item?.reasonRepost)
|
const isRepost = Boolean(item?.reasonRepost)
|
||||||
return (
|
return (
|
||||||
|
@ -267,6 +267,8 @@ export class FeedModel {
|
||||||
item.post.upvoteCount >= 2
|
item.post.upvoteCount >= 2
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
return this.feed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -293,6 +295,14 @@ export class FeedModel {
|
||||||
this.feed = []
|
this.feed = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switchFeedType(feedType: 'home' | 'suggested') {
|
||||||
|
if (this.feedType === feedType) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.feedType = feedType
|
||||||
|
return this.setup()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load for first render
|
* Load for first render
|
||||||
*/
|
*/
|
||||||
|
@ -427,7 +437,7 @@ export class FeedModel {
|
||||||
* Check if new posts are available
|
* Check if new posts are available
|
||||||
*/
|
*/
|
||||||
async checkForLatest() {
|
async checkForLatest() {
|
||||||
if (this.hasNewLatest || this.rootStore.me.follows.isEmpty) {
|
if (this.hasNewLatest || this.feedType === 'suggested') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const res = await this._getFeed({limit: 1})
|
const res = await this._getFeed({limit: 1})
|
||||||
|
@ -562,30 +572,25 @@ export class FeedModel {
|
||||||
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {},
|
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {},
|
||||||
): Promise<GetTimeline.Response | GetAuthorFeed.Response> {
|
): Promise<GetTimeline.Response | GetAuthorFeed.Response> {
|
||||||
params = Object.assign({}, this.params, params)
|
params = Object.assign({}, this.params, params)
|
||||||
if (this.feedType === 'home') {
|
if (this.feedType === 'suggested') {
|
||||||
await this.rootStore.me.follows.fetchIfNeeded()
|
const responses = await getMultipleAuthorsPosts(
|
||||||
if (this.rootStore.me.follows.isEmpty) {
|
this.rootStore,
|
||||||
const responses = await getMultipleAuthorsPosts(
|
sampleSize(SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), 20),
|
||||||
this.rootStore,
|
params.before,
|
||||||
sampleSize(
|
20,
|
||||||
SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)),
|
)
|
||||||
20,
|
const combinedCursor = getCombinedCursors(responses)
|
||||||
),
|
const finalData = mergePosts(responses, {bestOfOnly: true})
|
||||||
params.before,
|
const lastHeaders = responses[responses.length - 1].headers
|
||||||
20,
|
return {
|
||||||
)
|
success: true,
|
||||||
const combinedCursor = getCombinedCursors(responses)
|
data: {
|
||||||
const finalData = mergePosts(responses, {bestOfOnly: true})
|
feed: finalData,
|
||||||
const lastHeaders = responses[responses.length - 1].headers
|
cursor: combinedCursor,
|
||||||
return {
|
},
|
||||||
success: true,
|
headers: lastHeaders,
|
||||||
data: {
|
|
||||||
feed: finalData,
|
|
||||||
cursor: combinedCursor,
|
|
||||||
},
|
|
||||||
headers: lastHeaders,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else if (this.feedType === 'home') {
|
||||||
return this.rootStore.api.app.bsky.feed.getTimeline(
|
return this.rootStore.api.app.bsky.feed.getTimeline(
|
||||||
params as GetTimeline.QueryParams,
|
params as GetTimeline.QueryParams,
|
||||||
)
|
)
|
||||||
|
|
|
@ -72,6 +72,10 @@ export class MyFollowsModel {
|
||||||
return !!this.followDidToRecordMap[did]
|
return !!this.followDidToRecordMap[did]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get numFollows() {
|
||||||
|
return Object.keys(this.followDidToRecordMap).length
|
||||||
|
}
|
||||||
|
|
||||||
get isEmpty() {
|
get isEmpty() {
|
||||||
return Object.keys(this.followDidToRecordMap).length === 0
|
return Object.keys(this.followDidToRecordMap).length === 0
|
||||||
}
|
}
|
||||||
|
|
|
@ -345,6 +345,7 @@ export class SessionModel {
|
||||||
)
|
)
|
||||||
|
|
||||||
this.setActiveSession(agent, did)
|
this.setActiveSession(agent, did)
|
||||||
|
this.rootStore.shell.setOnboarding(true)
|
||||||
this.rootStore.log.debug('SessionModel:createAccount succeeded')
|
this.rootStore.log.debug('SessionModel:createAccount succeeded')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -118,6 +118,7 @@ export class ShellUiModel {
|
||||||
activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined
|
activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined
|
||||||
isComposerActive = false
|
isComposerActive = false
|
||||||
composerOpts: ComposerOpts | undefined
|
composerOpts: ComposerOpts | undefined
|
||||||
|
isOnboarding = false
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
makeAutoObservable(this, {
|
makeAutoObservable(this, {
|
||||||
|
@ -185,4 +186,13 @@ export class ShellUiModel {
|
||||||
this.isComposerActive = false
|
this.isComposerActive = false
|
||||||
this.composerOpts = undefined
|
this.composerOpts = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setOnboarding(v: boolean) {
|
||||||
|
this.isOnboarding = v
|
||||||
|
if (this.isOnboarding) {
|
||||||
|
this.rootStore.me.mainFeed.switchFeedType('suggested')
|
||||||
|
} else {
|
||||||
|
this.rootStore.me.mainFeed.switchFeedType('home')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,26 +7,28 @@ import {
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
|
||||||
import {CenteredView, FlatList} from '../util/Views'
|
import {CenteredView, FlatList} from '../util/Views'
|
||||||
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||||
import {EmptyState} from '../util/EmptyState'
|
import {Text} from '../util/text/Text'
|
||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||||
|
import {Button} from '../util/forms/Button'
|
||||||
import {FeedModel} from 'state/models/feed-view'
|
import {FeedModel} from 'state/models/feed-view'
|
||||||
import {FeedItem} from './FeedItem'
|
import {FeedItem} from './FeedItem'
|
||||||
import {WelcomeBanner} from '../util/WelcomeBanner'
|
|
||||||
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
|
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {useAnalytics} from 'lib/analytics'
|
|
||||||
import {useStores} from 'state/index'
|
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 EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||||
const ERROR_FEED_ITEM = {_reactKey: '__error__'}
|
const ERROR_FEED_ITEM = {_reactKey: '__error__'}
|
||||||
const WELCOME_FEED_ITEM = {_reactKey: '__welcome__'}
|
|
||||||
|
|
||||||
export const Feed = observer(function Feed({
|
export const Feed = observer(function Feed({
|
||||||
feed,
|
feed,
|
||||||
style,
|
style,
|
||||||
showWelcomeBanner,
|
|
||||||
showPostFollowBtn,
|
showPostFollowBtn,
|
||||||
scrollElRef,
|
scrollElRef,
|
||||||
onPressTryAgain,
|
onPressTryAgain,
|
||||||
|
@ -36,7 +38,6 @@ export const Feed = observer(function Feed({
|
||||||
}: {
|
}: {
|
||||||
feed: FeedModel
|
feed: FeedModel
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
showWelcomeBanner?: boolean
|
|
||||||
showPostFollowBtn?: boolean
|
showPostFollowBtn?: boolean
|
||||||
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||||
onPressTryAgain?: () => void
|
onPressTryAgain?: () => void
|
||||||
|
@ -44,10 +45,11 @@ export const Feed = observer(function Feed({
|
||||||
testID?: string
|
testID?: string
|
||||||
headerOffset?: number
|
headerOffset?: number
|
||||||
}) {
|
}) {
|
||||||
const {track} = useAnalytics()
|
const pal = usePalette('default')
|
||||||
|
const palInverted = usePalette('inverted')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
const {track} = useAnalytics()
|
||||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||||
const [isNewUser, setIsNewUser] = React.useState<boolean>(false)
|
|
||||||
|
|
||||||
const data = React.useMemo(() => {
|
const data = React.useMemo(() => {
|
||||||
let feedItems: any[] = []
|
let feedItems: any[] = []
|
||||||
|
@ -55,9 +57,6 @@ export const Feed = observer(function Feed({
|
||||||
if (feed.hasError) {
|
if (feed.hasError) {
|
||||||
feedItems = feedItems.concat([ERROR_FEED_ITEM])
|
feedItems = feedItems.concat([ERROR_FEED_ITEM])
|
||||||
}
|
}
|
||||||
if (showWelcomeBanner && isNewUser) {
|
|
||||||
feedItems = feedItems.concat([WELCOME_FEED_ITEM])
|
|
||||||
}
|
|
||||||
if (feed.isEmpty) {
|
if (feed.isEmpty) {
|
||||||
feedItems = feedItems.concat([EMPTY_FEED_ITEM])
|
feedItems = feedItems.concat([EMPTY_FEED_ITEM])
|
||||||
} else {
|
} else {
|
||||||
|
@ -65,39 +64,21 @@ export const Feed = observer(function Feed({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return feedItems
|
return feedItems
|
||||||
}, [
|
}, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.nonReplyFeed])
|
||||||
feed.hasError,
|
|
||||||
feed.hasLoaded,
|
|
||||||
feed.isEmpty,
|
|
||||||
feed.nonReplyFeed,
|
|
||||||
showWelcomeBanner,
|
|
||||||
isNewUser,
|
|
||||||
])
|
|
||||||
|
|
||||||
// events
|
// 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 () => {
|
const onRefresh = React.useCallback(async () => {
|
||||||
track('Feed:onRefresh')
|
track('Feed:onRefresh')
|
||||||
setIsRefreshing(true)
|
setIsRefreshing(true)
|
||||||
checkWelcome()
|
|
||||||
try {
|
try {
|
||||||
await feed.refresh()
|
await feed.refresh()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
feed.rootStore.log.error('Failed to refresh posts feed', err)
|
feed.rootStore.log.error('Failed to refresh posts feed', err)
|
||||||
}
|
}
|
||||||
setIsRefreshing(false)
|
setIsRefreshing(false)
|
||||||
}, [feed, track, setIsRefreshing, checkWelcome])
|
}, [feed, track, setIsRefreshing])
|
||||||
const onEndReached = React.useCallback(async () => {
|
const onEndReached = React.useCallback(async () => {
|
||||||
track('Feed:onEndReached')
|
track('Feed:onEndReached')
|
||||||
try {
|
try {
|
||||||
|
@ -118,11 +99,30 @@ export const Feed = observer(function Feed({
|
||||||
({item}: {item: any}) => {
|
({item}: {item: any}) => {
|
||||||
if (item === EMPTY_FEED_ITEM) {
|
if (item === EMPTY_FEED_ITEM) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<View style={styles.emptyContainer}>
|
||||||
icon="bars"
|
<View style={styles.emptyIconContainer}>
|
||||||
message="This feed is empty!"
|
<MagnifyingGlassIcon
|
||||||
style={styles.emptyState}
|
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) {
|
} else if (item === ERROR_FEED_ITEM) {
|
||||||
return (
|
return (
|
||||||
|
@ -131,12 +131,10 @@ export const Feed = observer(function Feed({
|
||||||
onPressTryAgain={onPressTryAgain}
|
onPressTryAgain={onPressTryAgain}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else if (item === WELCOME_FEED_ITEM) {
|
|
||||||
return <WelcomeBanner />
|
|
||||||
}
|
}
|
||||||
return <FeedItem item={item} showFollowBtn={showPostFollowBtn} />
|
return <FeedItem item={item} showFollowBtn={showPostFollowBtn} />
|
||||||
},
|
},
|
||||||
[feed, onPressTryAgain, showPostFollowBtn],
|
[feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, store.nav],
|
||||||
)
|
)
|
||||||
|
|
||||||
const FeedFooter = React.useCallback(
|
const FeedFooter = React.useCallback(
|
||||||
|
@ -155,7 +153,6 @@ export const Feed = observer(function Feed({
|
||||||
<View testID={testID} style={style}>
|
<View testID={testID} style={style}>
|
||||||
{feed.isLoading && data.length === 0 && (
|
{feed.isLoading && data.length === 0 && (
|
||||||
<CenteredView style={{paddingTop: headerOffset}}>
|
<CenteredView style={{paddingTop: headerOffset}}>
|
||||||
{showWelcomeBanner && isNewUser && <WelcomeBanner />}
|
|
||||||
<PostFeedLoadingPlaceholder />
|
<PostFeedLoadingPlaceholder />
|
||||||
</CenteredView>
|
</CenteredView>
|
||||||
)}
|
)}
|
||||||
|
@ -184,5 +181,21 @@ export const Feed = observer(function Feed({
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
feedFooter: {paddingTop: 20},
|
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 React from 'react'
|
||||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import {Text} from '../util/text/Text'
|
import {Button} from '../util/forms/Button'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import * as apilib from 'lib/api/index'
|
import * as apilib from 'lib/api/index'
|
||||||
import * as Toast from '../util/Toast'
|
import * as Toast from '../util/Toast'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
|
||||||
|
|
||||||
const FollowButton = observer(
|
const FollowButton = observer(
|
||||||
({did, declarationCid}: {did: string; declarationCid: string}) => {
|
({
|
||||||
|
did,
|
||||||
|
declarationCid,
|
||||||
|
onToggleFollow,
|
||||||
|
}: {
|
||||||
|
did: string
|
||||||
|
declarationCid: string
|
||||||
|
onToggleFollow?: (v: boolean) => void
|
||||||
|
}) => {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
|
||||||
const isFollowing = store.me.follows.isFollowing(did)
|
const isFollowing = store.me.follows.isFollowing(did)
|
||||||
|
|
||||||
const onToggleFollow = async () => {
|
const onToggleFollowInner = async () => {
|
||||||
if (store.me.follows.isFollowing(did)) {
|
if (store.me.follows.isFollowing(did)) {
|
||||||
try {
|
try {
|
||||||
await apilib.unfollow(store, store.me.follows.getFollowUri(did))
|
await apilib.unfollow(store, store.me.follows.getFollowUri(did))
|
||||||
store.me.follows.removeFollow(did)
|
store.me.follows.removeFollow(did)
|
||||||
|
onToggleFollow?.(false)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
store.log.error('Failed fo delete follow', e)
|
store.log.error('Failed fo delete follow', e)
|
||||||
Toast.show('An issue occurred, please try again.')
|
Toast.show('An issue occurred, please try again.')
|
||||||
|
@ -26,6 +32,7 @@ const FollowButton = observer(
|
||||||
try {
|
try {
|
||||||
const res = await apilib.follow(store, did, declarationCid)
|
const res = await apilib.follow(store, did, declarationCid)
|
||||||
store.me.follows.addFollow(did, res.uri)
|
store.me.follows.addFollow(did, res.uri)
|
||||||
|
onToggleFollow?.(true)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
store.log.error('Failed fo create follow', e)
|
store.log.error('Failed fo create follow', e)
|
||||||
Toast.show('An issue occurred, please try again.')
|
Toast.show('An issue occurred, please try again.')
|
||||||
|
@ -34,24 +41,13 @@ const FollowButton = observer(
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={onToggleFollow}>
|
<Button
|
||||||
<View style={[styles.btn, pal.btn]}>
|
type={isFollowing ? 'default' : 'primary'}
|
||||||
<Text type="button" style={[pal.text]}>
|
onPress={onToggleFollowInner}
|
||||||
{isFollowing ? 'Unfollow' : 'Follow'}
|
label={isFollowing ? 'Unfollow' : 'Follow'}
|
||||||
</Text>
|
/>
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
export default FollowButton
|
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
|
let handle = opts.authorHandle
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const isMe = opts.did === store.me.did
|
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
|
const [didFollow, setDidFollow] = React.useState(false)
|
||||||
// don't change this UI immediately, but rather upon future
|
const onToggleFollow = React.useCallback(() => {
|
||||||
// renders
|
setDidFollow(true)
|
||||||
const isFollowing = React.useMemo(
|
}, [setDidFollow])
|
||||||
() =>
|
|
||||||
typeof opts.did === 'string' && store.me.follows.isFollowing(opts.did),
|
|
||||||
[opts.did, store.me.follows],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
opts.showFollowBtn &&
|
opts.showFollowBtn &&
|
||||||
!isMe &&
|
!isMe &&
|
||||||
!isFollowing &&
|
(!isFollowing || didFollow) &&
|
||||||
opts.did &&
|
opts.did &&
|
||||||
opts.declarationCid
|
opts.declarationCid
|
||||||
) {
|
) {
|
||||||
|
@ -71,7 +69,11 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<FollowButton did={opts.did} declarationCid={opts.declarationCid} />
|
<FollowButton
|
||||||
|
did={opts.did}
|
||||||
|
declarationCid={opts.declarationCid}
|
||||||
|
onToggleFollow={onToggleFollow}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,11 +1,43 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {StyleSheet, View} from 'react-native'
|
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 {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {Text} from './text/Text'
|
import {Text} from './text/Text'
|
||||||
|
import {Button} from './forms/Button'
|
||||||
import {s} from 'lib/styles'
|
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 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 (
|
return (
|
||||||
<View
|
<View
|
||||||
testID="welcomeBanner"
|
testID="welcomeBanner"
|
||||||
|
@ -16,18 +48,53 @@ export function WelcomeBanner() {
|
||||||
lineHeight={1.1}>
|
lineHeight={1.1}>
|
||||||
Welcome to the private beta!
|
Welcome to the private beta!
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="lg" style={[pal.text, s.textCenter]}>
|
{isReady ? (
|
||||||
Here are some recent posts. Follow their creators to build your feed.
|
<View style={styles.controls}>
|
||||||
</Text>
|
<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>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
paddingTop: 30,
|
paddingTop: 16,
|
||||||
paddingBottom: 26,
|
paddingBottom: 16,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
borderTopWidth: 1,
|
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 =
|
export type ButtonType =
|
||||||
| 'primary'
|
| 'primary'
|
||||||
| 'secondary'
|
| 'secondary'
|
||||||
|
| 'default'
|
||||||
| 'inverted'
|
| 'inverted'
|
||||||
| 'primary-outline'
|
| 'primary-outline'
|
||||||
| 'secondary-outline'
|
| 'secondary-outline'
|
||||||
|
@ -40,6 +41,9 @@ export function Button({
|
||||||
secondary: {
|
secondary: {
|
||||||
backgroundColor: theme.palette.secondary.background,
|
backgroundColor: theme.palette.secondary.background,
|
||||||
},
|
},
|
||||||
|
default: {
|
||||||
|
backgroundColor: theme.palette.default.backgroundLight,
|
||||||
|
},
|
||||||
inverted: {
|
inverted: {
|
||||||
backgroundColor: theme.palette.inverted.background,
|
backgroundColor: theme.palette.inverted.background,
|
||||||
},
|
},
|
||||||
|
@ -66,15 +70,18 @@ export function Button({
|
||||||
const labelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, {
|
const labelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, {
|
||||||
primary: {
|
primary: {
|
||||||
color: theme.palette.primary.text,
|
color: theme.palette.primary.text,
|
||||||
fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined,
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
color: theme.palette.secondary.text,
|
color: theme.palette.secondary.text,
|
||||||
fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined,
|
fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined,
|
||||||
},
|
},
|
||||||
|
default: {
|
||||||
|
color: theme.palette.default.text,
|
||||||
|
},
|
||||||
inverted: {
|
inverted: {
|
||||||
color: theme.palette.inverted.text,
|
color: theme.palette.inverted.text,
|
||||||
fontWeight: theme.palette.inverted.isLowContrast ? '500' : undefined,
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
'primary-outline': {
|
'primary-outline': {
|
||||||
color: theme.palette.primary.textInverted,
|
color: theme.palette.primary.textInverted,
|
||||||
|
@ -114,7 +121,8 @@ export function Button({
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
outer: {
|
outer: {
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
|
borderRadius: 24,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -341,6 +341,9 @@ function ButtonsView() {
|
||||||
<View style={[s.flexRow, s.mb5]}>
|
<View style={[s.flexRow, s.mb5]}>
|
||||||
<Button type="primary" label="Primary solid" style={buttonStyles} />
|
<Button type="primary" label="Primary solid" style={buttonStyles} />
|
||||||
<Button type="secondary" label="Secondary solid" style={buttonStyles} />
|
<Button type="secondary" label="Secondary solid" style={buttonStyles} />
|
||||||
|
</View>
|
||||||
|
<View style={[s.flexRow, s.mb5]}>
|
||||||
|
<Button type="default" label="Default solid" style={buttonStyles} />
|
||||||
<Button type="inverted" label="Inverted solid" style={buttonStyles} />
|
<Button type="inverted" label="Inverted solid" style={buttonStyles} />
|
||||||
</View>
|
</View>
|
||||||
<View style={s.flexRow}>
|
<View style={s.flexRow}>
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import React, {useEffect} from 'react'
|
import React from 'react'
|
||||||
import {FlatList, View} from 'react-native'
|
import {FlatList, View} from 'react-native'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import useAppState from 'react-native-appstate-hook'
|
import useAppState from 'react-native-appstate-hook'
|
||||||
import {ViewHeader} from '../com/util/ViewHeader'
|
import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
import {Feed} from '../com/posts/Feed'
|
import {Feed} from '../com/posts/Feed'
|
||||||
import {LoadLatestBtn} from '../com/util/LoadLatestBtn'
|
import {LoadLatestBtn} from '../com/util/LoadLatestBtn'
|
||||||
|
import {WelcomeBanner} from '../com/util/WelcomeBanner'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {ScreenParams} from '../routes'
|
import {ScreenParams} from '../routes'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
|
@ -43,7 +44,7 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
|
||||||
scrollElRef.current?.scrollToOffset({offset: -HEADER_HEIGHT})
|
scrollElRef.current?.scrollToOffset({offset: -HEADER_HEIGHT})
|
||||||
}, [scrollElRef])
|
}, [scrollElRef])
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
const softResetSub = store.onScreenSoftReset(scrollToTop)
|
const softResetSub = store.onScreenSoftReset(scrollToTop)
|
||||||
const feedCleanup = store.me.mainFeed.registerListeners()
|
const feedCleanup = store.me.mainFeed.registerListeners()
|
||||||
const pollInterval = setInterval(doPoll, 15e3)
|
const pollInterval = setInterval(doPoll, 15e3)
|
||||||
|
@ -72,7 +73,16 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
|
||||||
store.me.mainFeed.update()
|
store.me.mainFeed.update()
|
||||||
}
|
}
|
||||||
return cleanup
|
return cleanup
|
||||||
}, [visible, store, store.me.mainFeed, navIdx, doPoll, wasVisible, scrollToTop, screen])
|
}, [
|
||||||
|
visible,
|
||||||
|
store,
|
||||||
|
store.me.mainFeed,
|
||||||
|
navIdx,
|
||||||
|
doPoll,
|
||||||
|
wasVisible,
|
||||||
|
scrollToTop,
|
||||||
|
screen,
|
||||||
|
])
|
||||||
|
|
||||||
const onPressTryAgain = () => {
|
const onPressTryAgain = () => {
|
||||||
store.me.mainFeed.refresh()
|
store.me.mainFeed.refresh()
|
||||||
|
@ -84,19 +94,21 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={s.hContentRegion}>
|
<View style={s.hContentRegion}>
|
||||||
|
{store.shell.isOnboarding && <WelcomeBanner />}
|
||||||
<Feed
|
<Feed
|
||||||
testID="homeFeed"
|
testID="homeFeed"
|
||||||
key="default"
|
key="default"
|
||||||
feed={store.me.mainFeed}
|
feed={store.me.mainFeed}
|
||||||
scrollElRef={scrollElRef}
|
scrollElRef={scrollElRef}
|
||||||
style={s.hContentRegion}
|
style={s.hContentRegion}
|
||||||
showWelcomeBanner
|
|
||||||
showPostFollowBtn
|
showPostFollowBtn
|
||||||
onPressTryAgain={onPressTryAgain}
|
onPressTryAgain={onPressTryAgain}
|
||||||
onScroll={onMainScroll}
|
onScroll={onMainScroll}
|
||||||
headerOffset={HEADER_HEIGHT}
|
headerOffset={store.shell.isOnboarding ? 0 : HEADER_HEIGHT}
|
||||||
/>
|
/>
|
||||||
<ViewHeader title="Bluesky" canGoBack={false} hideOnScroll />
|
{!store.shell.isOnboarding && (
|
||||||
|
<ViewHeader title="Bluesky" canGoBack={false} hideOnScroll />
|
||||||
|
)}
|
||||||
{store.me.mainFeed.hasNewLatest && !store.me.mainFeed.isRefreshing && (
|
{store.me.mainFeed.hasNewLatest && !store.me.mainFeed.isRefreshing && (
|
||||||
<LoadLatestBtn onPress={onPressLoadLatest} />
|
<LoadLatestBtn onPress={onPressLoadLatest} />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -131,14 +131,10 @@ export const Menu = observer(({onClose}: {onClose: () => void}) => {
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
type="title-lg"
|
type="title-lg"
|
||||||
style={[pal.text, s.bold, styles.profileCardDisplayName]}
|
style={[pal.text, s.bold, styles.profileCardDisplayName]}>
|
||||||
numberOfLines={1}>
|
|
||||||
{store.me.displayName || store.me.handle}
|
{store.me.displayName || store.me.handle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}>
|
||||||
type="2xl"
|
|
||||||
style={[pal.textLight, styles.profileCardHandle]}
|
|
||||||
numberOfLines={1}>
|
|
||||||
@{store.me.handle}
|
@{store.me.handle}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
@ -280,9 +276,11 @@ const styles = StyleSheet.create({
|
||||||
|
|
||||||
profileCardDisplayName: {
|
profileCardDisplayName: {
|
||||||
marginTop: 20,
|
marginTop: 20,
|
||||||
|
paddingRight: 20,
|
||||||
},
|
},
|
||||||
profileCardHandle: {
|
profileCardHandle: {
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
|
paddingRight: 20,
|
||||||
},
|
},
|
||||||
|
|
||||||
menuItem: {
|
menuItem: {
|
||||||
|
|
Loading…
Reference in New Issue