From e574c8404a7c563bad0828cd4e276f43ff203cc9 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 6 Mar 2023 15:34:22 -0600 Subject: [PATCH] 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 --- src/lib/api/build-suggested-posts.ts | 21 +++++- src/state/models/feed-view.ts | 57 ++++++++-------- src/state/models/my-follows.ts | 4 ++ src/state/models/session.ts | 1 + src/state/models/shell-ui.ts | 10 +++ src/view/com/posts/Feed.tsx | 95 +++++++++++++++------------ src/view/com/profile/FollowButton.tsx | 40 +++++------ src/view/com/util/PostMeta.tsx | 22 ++++--- src/view/com/util/WelcomeBanner.tsx | 81 +++++++++++++++++++++-- src/view/com/util/forms/Button.tsx | 14 +++- src/view/screens/Debug.tsx | 3 + src/view/screens/Home.tsx | 24 +++++-- src/view/shell/mobile/Menu.tsx | 10 ++- 13 files changed, 259 insertions(+), 123 deletions(-) diff --git a/src/lib/api/build-suggested-posts.ts b/src/lib/api/build-suggested-posts.ts index 6250f4a9c0..defa45311f 100644 --- a/src/lib/api/build-suggested-posts.ts +++ b/src/lib/api/build-suggested-posts.ts @@ -37,13 +37,20 @@ function mergePosts( // 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) { + if ( + !acc?.[0] && + !v.reason && + !v.reply && + isRecentEnough(v.post.indexedAt) + ) { return [v] } if ( acc && !v.reason && - v.post.upvoteCount > acc[0].post.upvoteCount + !v.reply && + v.post.upvoteCount > acc[0]?.post.upvoteCount && + isRecentEnough(v.post.indexedAt) ) { return [v] } @@ -112,6 +119,16 @@ function isCombinedCursor(cursor: string) { 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 { getMultipleAuthorsPosts, mergePosts, diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts index 535221e631..e27712d115 100644 --- a/src/state/models/feed-view.ts +++ b/src/state/models/feed-view.ts @@ -212,7 +212,7 @@ export class FeedModel { constructor( public rootStore: RootStoreModel, - public feedType: 'home' | 'author', + public feedType: 'home' | 'author' | 'suggested', params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams, ) { makeAutoObservable( @@ -256,7 +256,7 @@ export class FeedModel { item.reply?.root.author.did === item.post.author.did) ) }) - } else { + } else if (this.feedType === 'home') { return this.feed.filter(item => { const isRepost = Boolean(item?.reasonRepost) return ( @@ -267,6 +267,8 @@ export class FeedModel { item.post.upvoteCount >= 2 ) }) + } else { + return this.feed } } @@ -293,6 +295,14 @@ export class FeedModel { this.feed = [] } + switchFeedType(feedType: 'home' | 'suggested') { + if (this.feedType === feedType) { + return + } + this.feedType = feedType + return this.setup() + } + /** * Load for first render */ @@ -427,7 +437,7 @@ export class FeedModel { * Check if new posts are available */ async checkForLatest() { - if (this.hasNewLatest || this.rootStore.me.follows.isEmpty) { + if (this.hasNewLatest || this.feedType === 'suggested') { return } const res = await this._getFeed({limit: 1}) @@ -562,30 +572,25 @@ export class FeedModel { params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {}, ): Promise { 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, - sampleSize( - SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), - 20, - ), - 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, - } + if (this.feedType === 'suggested') { + const responses = await getMultipleAuthorsPosts( + this.rootStore, + sampleSize(SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), 20), + 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, } + } else if (this.feedType === 'home') { return this.rootStore.api.app.bsky.feed.getTimeline( params as GetTimeline.QueryParams, ) diff --git a/src/state/models/my-follows.ts b/src/state/models/my-follows.ts index c1fba13522..732c2fe738 100644 --- a/src/state/models/my-follows.ts +++ b/src/state/models/my-follows.ts @@ -72,6 +72,10 @@ export class MyFollowsModel { return !!this.followDidToRecordMap[did] } + get numFollows() { + return Object.keys(this.followDidToRecordMap).length + } + get isEmpty() { return Object.keys(this.followDidToRecordMap).length === 0 } diff --git a/src/state/models/session.ts b/src/state/models/session.ts index 75a60f3531..b15c866f41 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -345,6 +345,7 @@ export class SessionModel { ) this.setActiveSession(agent, did) + this.rootStore.shell.setOnboarding(true) this.rootStore.log.debug('SessionModel:createAccount succeeded') } diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts index 1b0e350a29..0dad2bd9ea 100644 --- a/src/state/models/shell-ui.ts +++ b/src/state/models/shell-ui.ts @@ -118,6 +118,7 @@ export class ShellUiModel { activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined isComposerActive = false composerOpts: ComposerOpts | undefined + isOnboarding = false constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, { @@ -185,4 +186,13 @@ export class ShellUiModel { this.isComposerActive = false 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') + } + } } diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 7ed6bc711a..f919c6208a 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -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 - showWelcomeBanner?: boolean showPostFollowBtn?: boolean scrollElRef?: MutableRefObject | 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(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 ( - + + + + + + Your feed is empty! You should follow some accounts to fix this. + + + ) } 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 } return }, - [feed, onPressTryAgain, showPostFollowBtn], + [feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, store.nav], ) const FeedFooter = React.useCallback( @@ -155,7 +153,6 @@ export const Feed = observer(function Feed({ {feed.isLoading && data.length === 0 && ( - {showWelcomeBanner && isNewUser && } )} @@ -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', + }, }) diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index 71462bea89..f24c3d0c90 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -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 ( - - - - {isFollowing ? 'Unfollow' : 'Follow'} - - - + + + ) : ( + <> + + Follow at least {remaining} {remaining === 1 ? 'person' : 'people'}{' '} + to build your feed. + + + + + + )} ) -} +}) 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, }, }) diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index a070d2f0f3..f3f4d1c792 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -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>(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, }, }) diff --git a/src/view/screens/Debug.tsx b/src/view/screens/Debug.tsx index 657f38d578..f2349195ea 100644 --- a/src/view/screens/Debug.tsx +++ b/src/view/screens/Debug.tsx @@ -341,6 +341,9 @@ function ButtonsView() {