diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-10-04 08:57:23 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-10-04 08:57:23 -0700 |
commit | b1a1bae02e021e509f678ba423a4d030166a02a9 (patch) | |
tree | 4143d3befce048701229111c6203e9493c225b73 | |
parent | a76fb78d532e436b6b84efd09d70088410a2bb20 (diff) | |
download | voidsky-b1a1bae02e021e509f678ba423a4d030166a02a9.tar.zst |
Onboarding & feed fixes (#1602)
* Fix: improve the 'end of feed' detection condition * Fix the feeds link on mobile in the empty state * Align the following empty state better on web * Dont autofocus the search input in the search tab * Fix the error boundary render * Add 'end of feed' CTA to following feed * Reduce the default feeds to discover now that we have feed-selection during onboarding * Fix case where loading spinner fails to stop rendering in bottom of feed * Fix: dont show loading spinner at footer of feed when refreshing * Fix: dont fire reminders during onboarding * Optimize adding feeds and update to mirror the api behaviors more closely * Use the lock in preferences to avoid clobbering in-flight updates * Refresh the feed after onboarding to ensure content is visible * Remove the now-incorrect comment * Tune copy
-rw-r--r-- | src/lib/constants.ts | 13 | ||||
-rw-r--r-- | src/state/models/discovery/onboarding.ts | 1 | ||||
-rw-r--r-- | src/state/models/feeds/posts.ts | 6 | ||||
-rw-r--r-- | src/state/models/ui/preferences.ts | 111 | ||||
-rw-r--r-- | src/state/models/ui/reminders.ts | 7 | ||||
-rw-r--r-- | src/view/com/auth/onboarding/RecommendedFeedsItem.tsx | 1 | ||||
-rw-r--r-- | src/view/com/posts/Feed.tsx | 8 | ||||
-rw-r--r-- | src/view/com/posts/FollowingEmptyState.tsx | 98 | ||||
-rw-r--r-- | src/view/com/posts/FollowingEndOfFeed.tsx | 100 | ||||
-rw-r--r-- | src/view/com/search/HeaderWithInput.tsx | 2 | ||||
-rw-r--r-- | src/view/com/util/ErrorBoundary.tsx | 2 | ||||
-rw-r--r-- | src/view/screens/Home.tsx | 9 |
12 files changed, 262 insertions, 96 deletions
diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1a7949e6a..81a6d4e77 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -79,6 +79,7 @@ export async function DEFAULT_FEEDS( serviceUrl: string, resolveHandle: (name: string) => Promise<string>, ) { + // TODO: remove this when the test suite no longer relies on it if (IS_LOCAL_DEV(serviceUrl)) { // local dev const aliceDid = await resolveHandle('alice.test') @@ -106,16 +107,8 @@ export async function DEFAULT_FEEDS( } else { // production return { - pinned: [ - PROD_DEFAULT_FEED('whats-hot'), - PROD_DEFAULT_FEED('with-friends'), - ], - saved: [ - PROD_DEFAULT_FEED('bsky-team'), - PROD_DEFAULT_FEED('with-friends'), - PROD_DEFAULT_FEED('whats-hot'), - PROD_DEFAULT_FEED('hot-classic'), - ], + pinned: [PROD_DEFAULT_FEED('whats-hot')], + saved: [PROD_DEFAULT_FEED('whats-hot')], } } } diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts index 8ad321ed9..3638e7f0d 100644 --- a/src/state/models/discovery/onboarding.ts +++ b/src/state/models/discovery/onboarding.ts @@ -81,6 +81,7 @@ export class OnboardingModel { } finish() { + this.rootStore.me.mainFeed.refresh() // load the selected content this.step = 'Home' track('Onboarding:Complete') } diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index bb619147f..2a7170325 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -116,6 +116,10 @@ export class PostsFeedModel { return this.hasLoaded && !this.hasContent } + get isLoadingMore() { + return this.isLoading && !this.isRefreshing + } + setHasNewLatest(v: boolean) { this.hasNewLatest = v } @@ -307,7 +311,7 @@ export class PostsFeedModel { } async _appendAll(res: FeedAPIResponse, replace = false) { - this.hasMore = !!res.cursor + this.hasMore = !!res.cursor && res.feed.length > 0 if (replace) { this.emptyFetches = 0 } diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index b3365bd7c..6ca19b4b7 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -418,6 +418,7 @@ export class PreferencesModel { const oldPinned = this.pinnedFeeds this.savedFeeds = saved this.pinnedFeeds = pinned + await this.lock.acquireAsync() try { const res = await cb() runInAction(() => { @@ -430,6 +431,8 @@ export class PreferencesModel { this.pinnedFeeds = oldPinned }) throw e + } finally { + this.lock.release() } } @@ -441,7 +444,7 @@ export class PreferencesModel { async addSavedFeed(v: string) { return this._optimisticUpdateSavedFeeds( - [...this.savedFeeds, v], + [...this.savedFeeds.filter(uri => uri !== v), v], this.pinnedFeeds, () => this.rootStore.agent.addSavedFeed(v), ) @@ -457,8 +460,8 @@ export class PreferencesModel { async addPinnedFeed(v: string) { return this._optimisticUpdateSavedFeeds( - this.savedFeeds, - [...this.pinnedFeeds, v], + [...this.savedFeeds.filter(uri => uri !== v), v], + [...this.pinnedFeeds.filter(uri => uri !== v), v], () => this.rootStore.agent.addPinnedFeed(v), ) } @@ -473,71 +476,121 @@ export class PreferencesModel { async setBirthDate(birthDate: Date) { this.birthDate = birthDate - await this.rootStore.agent.setPersonalDetails({birthDate}) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setPersonalDetails({birthDate}) + } finally { + this.lock.release() + } } async toggleHomeFeedHideReplies() { this.homeFeed.hideReplies = !this.homeFeed.hideReplies - await this.rootStore.agent.setFeedViewPrefs('home', { - hideReplies: this.homeFeed.hideReplies, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + hideReplies: this.homeFeed.hideReplies, + }) + } finally { + this.lock.release() + } } async toggleHomeFeedHideRepliesByUnfollowed() { this.homeFeed.hideRepliesByUnfollowed = !this.homeFeed.hideRepliesByUnfollowed - await this.rootStore.agent.setFeedViewPrefs('home', { - hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed, + }) + } finally { + this.lock.release() + } } async setHomeFeedHideRepliesByLikeCount(threshold: number) { this.homeFeed.hideRepliesByLikeCount = threshold - await this.rootStore.agent.setFeedViewPrefs('home', { - hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount, + }) + } finally { + this.lock.release() + } } async toggleHomeFeedHideReposts() { this.homeFeed.hideReposts = !this.homeFeed.hideReposts - await this.rootStore.agent.setFeedViewPrefs('home', { - hideReposts: this.homeFeed.hideReposts, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + hideReposts: this.homeFeed.hideReposts, + }) + } finally { + this.lock.release() + } } async toggleHomeFeedHideQuotePosts() { this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts - await this.rootStore.agent.setFeedViewPrefs('home', { - hideQuotePosts: this.homeFeed.hideQuotePosts, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + hideQuotePosts: this.homeFeed.hideQuotePosts, + }) + } finally { + this.lock.release() + } } async toggleHomeFeedMergeFeedEnabled() { this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled - await this.rootStore.agent.setFeedViewPrefs('home', { - lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled, + }) + } finally { + this.lock.release() + } } async setThreadSort(v: string) { if (THREAD_SORT_VALUES.includes(v)) { this.thread.sort = v - await this.rootStore.agent.setThreadViewPrefs({sort: v}) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setThreadViewPrefs({sort: v}) + } finally { + this.lock.release() + } } } async togglePrioritizedFollowedUsers() { this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers - await this.rootStore.agent.setThreadViewPrefs({ - prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setThreadViewPrefs({ + prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers, + }) + } finally { + this.lock.release() + } } async toggleThreadTreeViewEnabled() { this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled - await this.rootStore.agent.setThreadViewPrefs({ - lab_treeViewEnabled: this.thread.lab_treeViewEnabled, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setThreadViewPrefs({ + lab_treeViewEnabled: this.thread.lab_treeViewEnabled, + }) + } finally { + this.lock.release() + } } toggleRequireAltTextEnabled() { diff --git a/src/state/models/ui/reminders.ts b/src/state/models/ui/reminders.ts index f8becdec3..60dbf5d88 100644 --- a/src/state/models/ui/reminders.ts +++ b/src/state/models/ui/reminders.ts @@ -6,10 +6,6 @@ import {toHashCode} from 'lib/strings/helpers' const DAY = 60e3 * 24 * 1 // 1 day (ms) export class Reminders { - // NOTE - // by defaulting to the current date, we ensure that the user won't be nagged - // on first run (aka right after creating an account) - // -prf lastEmailConfirm: Date = new Date() constructor(public rootStore: RootStoreModel) { @@ -46,6 +42,9 @@ export class Reminders { if (sess.emailConfirmed) { return false } + if (this.rootStore.onboarding.isActive) { + return false + } const today = new Date() // shard the users into 2 day of the week buckets // (this is to avoid a sudden influx of email updates when diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx index d130dc138..6796c64db 100644 --- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx @@ -30,7 +30,6 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ } } else { try { - await item.save() await item.pin() } catch (e) { Toast.show('There was an issue contacting your server') diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 55e69a318..b095fe07b 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -33,6 +33,7 @@ export const Feed = observer(function Feed({ onScroll, scrollEventThrottle, renderEmptyState, + renderEndOfFeed, testID, headerOffset = 0, ListHeaderComponent, @@ -45,6 +46,7 @@ export const Feed = observer(function Feed({ onScroll?: OnScrollCb scrollEventThrottle?: number renderEmptyState?: () => JSX.Element + renderEndOfFeed?: () => JSX.Element testID?: string headerOffset?: number ListHeaderComponent?: () => JSX.Element @@ -142,14 +144,16 @@ export const Feed = observer(function Feed({ const FeedFooter = React.useCallback( () => - feed.isLoading ? ( + feed.isLoadingMore ? ( <View style={styles.feedFooter}> <ActivityIndicator /> </View> + ) : !feed.hasMore && !feed.isEmpty && renderEndOfFeed ? ( + renderEndOfFeed() ) : ( <View /> ), - [feed], + [feed.isLoadingMore, feed.hasMore, feed.isEmpty, renderEndOfFeed], ) return ( diff --git a/src/view/com/posts/FollowingEmptyState.tsx b/src/view/com/posts/FollowingEmptyState.tsx index a73ffb68b..61a27e48e 100644 --- a/src/view/com/posts/FollowingEmptyState.tsx +++ b/src/view/com/posts/FollowingEmptyState.tsx @@ -28,60 +28,73 @@ export function FollowingEmptyState() { }, [navigation]) const onPressDiscoverFeeds = React.useCallback(() => { - navigation.navigate('Feeds') + if (isWeb) { + navigation.navigate('Feeds') + } else { + navigation.navigate('FeedsTab') + navigation.popToTop() + } }, [navigation]) return ( - <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 following feed is empty! Find some accounts to follow to fix this. - </Text> - <Button - type="inverted" - style={styles.emptyBtn} - onPress={onPressFindAccounts}> - <Text type="lg-medium" style={palInverted.text}> - Find accounts to follow + <View style={styles.container}> + <View style={styles.inner}> + <View style={styles.iconContainer}> + <MagnifyingGlassIcon style={[styles.icon, pal.text]} size={62} /> + </View> + <Text type="xl-medium" style={[s.textCenter, pal.text]}> + Your following feed is empty! Follow more users to see what's + happening. </Text> - <FontAwesomeIcon - icon="angle-right" - style={palInverted.text as FontAwesomeIconStyle} - size={14} - /> - </Button> + <Button + type="inverted" + style={styles.emptyBtn} + onPress={onPressFindAccounts}> + <Text type="lg-medium" style={palInverted.text}> + Find accounts to follow + </Text> + <FontAwesomeIcon + icon="angle-right" + style={palInverted.text as FontAwesomeIconStyle} + size={14} + /> + </Button> - <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}> - You can also discover new Custom Feeds to follow. - </Text> - <Button - type="inverted" - style={[styles.emptyBtn, s.mt10]} - onPress={onPressDiscoverFeeds}> - <Text type="lg-medium" style={palInverted.text}> - Discover new custom feeds + <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}> + You can also discover new Custom Feeds to follow. </Text> - <FontAwesomeIcon - icon="angle-right" - style={palInverted.text as FontAwesomeIconStyle} - size={14} - /> - </Button> + <Button + type="inverted" + style={[styles.emptyBtn, s.mt10]} + onPress={onPressDiscoverFeeds}> + <Text type="lg-medium" style={palInverted.text}> + Discover new custom feeds + </Text> + <FontAwesomeIcon + icon="angle-right" + style={palInverted.text as FontAwesomeIconStyle} + size={14} + /> + </Button> + </View> </View> ) } const styles = StyleSheet.create({ - emptyContainer: { + container: { height: '100%', + flexDirection: 'row', + justifyContent: 'center', paddingVertical: 40, paddingHorizontal: 30, }, - emptyIconContainer: { + inner: { + maxWidth: 460, + }, + iconContainer: { marginBottom: 16, }, - emptyIcon: { + icon: { marginLeft: 'auto', marginRight: 'auto', }, @@ -94,13 +107,4 @@ const styles = StyleSheet.create({ paddingHorizontal: 24, borderRadius: 30, }, - - feedsTip: { - position: 'absolute', - left: 22, - }, - feedsTipArrow: { - marginLeft: 32, - marginTop: 8, - }, }) diff --git a/src/view/com/posts/FollowingEndOfFeed.tsx b/src/view/com/posts/FollowingEndOfFeed.tsx new file mode 100644 index 000000000..48724d8b3 --- /dev/null +++ b/src/view/com/posts/FollowingEndOfFeed.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {useNavigation} from '@react-navigation/native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {NavigationProp} from 'lib/routes/types' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' +import {isWeb} from 'platform/detection' + +export function FollowingEndOfFeed() { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const navigation = useNavigation<NavigationProp>() + + const onPressFindAccounts = React.useCallback(() => { + if (isWeb) { + navigation.navigate('Search', {}) + } else { + navigation.navigate('SearchTab') + navigation.popToTop() + } + }, [navigation]) + + const onPressDiscoverFeeds = React.useCallback(() => { + if (isWeb) { + navigation.navigate('Feeds') + } else { + navigation.navigate('FeedsTab') + navigation.popToTop() + } + }, [navigation]) + + return ( + <View style={[styles.container, pal.border]}> + <View style={styles.inner}> + <Text type="xl-medium" style={[s.textCenter, pal.text]}> + You've reached the end of your feed! Find some more accounts to + follow. + </Text> + <Button + type="inverted" + style={styles.emptyBtn} + onPress={onPressFindAccounts}> + <Text type="lg-medium" style={palInverted.text}> + Find accounts to follow + </Text> + <FontAwesomeIcon + icon="angle-right" + style={palInverted.text as FontAwesomeIconStyle} + size={14} + /> + </Button> + + <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}> + You can also discover new Custom Feeds to follow. + </Text> + <Button + type="inverted" + style={[styles.emptyBtn, s.mt10]} + onPress={onPressDiscoverFeeds}> + <Text type="lg-medium" style={palInverted.text}> + Discover new custom feeds + </Text> + <FontAwesomeIcon + icon="angle-right" + style={palInverted.text as FontAwesomeIconStyle} + size={14} + /> + </Button> + </View> + </View> + ) +} +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'center', + paddingTop: 40, + paddingBottom: 80, + paddingHorizontal: 30, + borderTopWidth: 1, + }, + inner: { + maxWidth: 460, + }, + emptyBtn: { + marginVertical: 20, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 18, + paddingHorizontal: 24, + borderRadius: 30, + }, +}) diff --git a/src/view/com/search/HeaderWithInput.tsx b/src/view/com/search/HeaderWithInput.tsx index f04175afd..6bd1b2f00 100644 --- a/src/view/com/search/HeaderWithInput.tsx +++ b/src/view/com/search/HeaderWithInput.tsx @@ -93,7 +93,7 @@ export function HeaderWithInput({ onBlur={() => setIsInputFocused(false)} onChangeText={onChangeQuery} onSubmitEditing={onSubmitQuery} - autoFocus={isMobile} + autoFocus={false} accessibilityRole="search" accessibilityLabel="Search" accessibilityHint="" diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx index c7374e195..529435cf1 100644 --- a/src/view/com/util/ErrorBoundary.tsx +++ b/src/view/com/util/ErrorBoundary.tsx @@ -28,7 +28,7 @@ export class ErrorBoundary extends Component<Props, State> { public render() { if (this.state.hasError) { return ( - <CenteredView> + <CenteredView style={{height: '100%', flex: 1}}> <ErrorScreen title="Oh no!" message="There was an unexpected issue in the application. Please let us know if this happened to you!" diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index e53d4a08e..8560ad445 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -13,6 +13,7 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {TextLink} from 'view/com/util/Link' import {Feed} from '../com/posts/Feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' +import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' import {FeedsTabBar} from '../com/pager/FeedsTabBar' @@ -110,6 +111,10 @@ export const HomeScreen = withAuthRequired( return <FollowingEmptyState /> }, []) + const renderFollowingEndOfFeed = React.useCallback(() => { + return <FollowingEndOfFeed /> + }, []) + const renderCustomFeedEmptyState = React.useCallback(() => { return <CustomFeedEmptyState /> }, []) @@ -127,6 +132,7 @@ export const HomeScreen = withAuthRequired( isPageFocused={selectedPage === 0} feed={store.me.mainFeed} renderEmptyState={renderFollowingEmptyState} + renderEndOfFeed={renderFollowingEndOfFeed} /> {customFeeds.map((f, index) => { return ( @@ -149,11 +155,13 @@ const FeedPage = observer(function FeedPageImpl({ isPageFocused, feed, renderEmptyState, + renderEndOfFeed, }: { testID?: string feed: PostsFeedModel isPageFocused: boolean renderEmptyState?: () => JSX.Element + renderEndOfFeed?: () => JSX.Element }) { const store = useStores() const pal = usePalette('default') @@ -307,6 +315,7 @@ const FeedPage = observer(function FeedPageImpl({ onScroll={onMainScroll} scrollEventThrottle={100} renderEmptyState={renderEmptyState} + renderEndOfFeed={renderEndOfFeed} ListHeaderComponent={ListHeaderComponent} headerOffset={headerOffset} /> |