Bundle of UI modifications (#175)

* Adjust visual balance of SuggestedPosts and WhoToFollow

* Fix bug in the discovery load trigger

* Adjust search header aesthetic and have it scroll away

* More visual balance tweaks on the search page

* Even more visual balance tweaks on the search page

* Hide the footer on scroll in search

* Ditch the composer prompt buttons in the home feed

* Center the view header title

* Hide header on scroll on the home feed
This commit is contained in:
Paul Frazee
2023-02-09 16:45:37 -06:00
committed by GitHub
parent 22820372df
commit 3ad0f3cc4a
8 changed files with 134 additions and 153 deletions
+2 -1
View File
@@ -25,7 +25,7 @@ export const SuggestedPosts = observer(() => {
return ( return (
<> <>
{(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && ( {(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && (
<Text type="lg-heavy" style={[styles.heading, pal.text]}> <Text type="title" style={[styles.heading, pal.text]}>
Recently, on Bluesky... Recently, on Bluesky...
</Text> </Text>
)} )}
@@ -49,6 +49,7 @@ export const SuggestedPosts = observer(() => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
heading: { heading: {
fontWeight: 'bold',
paddingHorizontal: 12, paddingHorizontal: 12,
paddingTop: 16, paddingTop: 16,
paddingBottom: 8, paddingBottom: 8,
+11 -30
View File
@@ -6,8 +6,6 @@ import {
View, View,
} from 'react-native' } from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import LinearGradient from 'react-native-linear-gradient'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import _omit from 'lodash.omit' import _omit from 'lodash.omit'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import { import {
@@ -15,7 +13,7 @@ import {
SuggestedActor, SuggestedActor,
} from '../../../state/models/suggested-actors-view' } from '../../../state/models/suggested-actors-view'
import * as apilib from '../../../state/lib/api' import * as apilib from '../../../state/lib/api'
import {s, gradients} from '../../lib/styles' import {s} from '../../lib/styles'
import {ProfileCard} from '../profile/ProfileCard' import {ProfileCard} from '../profile/ProfileCard'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
@@ -59,7 +57,7 @@ export const WhoToFollow = observer(() => {
return ( return (
<> <>
{(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && ( {(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && (
<Text type="lg-heavy" style={[styles.heading, pal.text]}> <Text type="title" style={[styles.heading, pal.text]}>
Who to follow Who to follow
</Text> </Text>
)} )}
@@ -86,7 +84,7 @@ export const WhoToFollow = observer(() => {
<TouchableOpacity <TouchableOpacity
onPress={onPressLoadMoreSuggestedActors} onPress={onPressLoadMoreSuggestedActors}
style={styles.loadMore}> style={styles.loadMore}>
<Text type="md-medium" style={pal.link}> <Text type="lg" style={pal.link}>
Show more Show more
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -110,33 +108,20 @@ function FollowBtn({
onPress: () => void onPress: () => void
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
if (isFollowing) {
return (
<TouchableOpacity onPress={onPress}>
<View style={[styles.btn, pal.btn]}>
<Text type="button" style={pal.text}>
Unfollow
</Text>
</View>
</TouchableOpacity>
)
}
return ( return (
<TouchableOpacity onPress={onPress}> <TouchableOpacity onPress={onPress}>
<LinearGradient <View style={[styles.btn, pal.btn]}>
colors={[gradients.blueLight.start, gradients.blueLight.end]} <Text type="button" style={[pal.text]}>
start={{x: 0, y: 0}} {isFollowing ? 'Unfollow' : 'Follow'}
end={{x: 1, y: 1}} </Text>
style={[styles.btn, styles.gradientBtn]}> </View>
<FontAwesomeIcon icon="plus" style={[s.white, s.mr5]} size={15} />
<Text style={[s.white, s.fw600, s.f15]}>Follow</Text>
</LinearGradient>
</TouchableOpacity> </TouchableOpacity>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
heading: { heading: {
fontWeight: 'bold',
paddingHorizontal: 12, paddingHorizontal: 12,
paddingTop: 16, paddingTop: 16,
paddingBottom: 8, paddingBottom: 8,
@@ -147,8 +132,8 @@ const styles = StyleSheet.create({
}, },
loadMore: { loadMore: {
paddingLeft: 12, paddingLeft: 16,
paddingVertical: 10, paddingVertical: 12,
}, },
btn: { btn: {
@@ -160,8 +145,4 @@ const styles = StyleSheet.create({
marginLeft: 6, marginLeft: 6,
paddingHorizontal: 14, paddingHorizontal: 14,
}, },
gradientBtn: {
paddingHorizontal: 24,
paddingVertical: 6,
},
}) })
+14 -11
View File
@@ -13,30 +13,29 @@ import {EmptyState} from '../util/EmptyState'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {FeedModel} from '../../../state/models/feed-view' import {FeedModel} from '../../../state/models/feed-view'
import {FeedItem} from './FeedItem' import {FeedItem} from './FeedItem'
import {PromptButtons} from './PromptButtons'
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 '@segment/analytics-react-native' import {useAnalytics} from '@segment/analytics-react-native'
const COMPOSE_PROMPT_ITEM = {_reactKey: '__prompt__'} const HEADER_SPACER_ITEM = {_reactKey: '__spacer__'}
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
export const Feed = observer(function Feed({ export const Feed = observer(function Feed({
feed, feed,
style, style,
scrollElRef, scrollElRef,
onPressCompose,
onPressTryAgain, onPressTryAgain,
onScroll, onScroll,
testID, testID,
headerSpacer,
}: { }: {
feed: FeedModel feed: FeedModel
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | null> scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressCompose: (imagesOpen?: boolean) => void
onPressTryAgain?: () => void onPressTryAgain?: () => void
onScroll?: OnScrollCb onScroll?: OnScrollCb
testID?: string testID?: string
headerSpacer?: boolean
}) { }) {
const {screen, track} = useAnalytics() const {screen, track} = useAnalytics()
@@ -49,9 +48,7 @@ export const Feed = observer(function Feed({
// renderItem function renders components that follow React performance best practices // renderItem function renders components that follow React performance best practices
// like PureComponent, shouldComponentUpdate, etc // like PureComponent, shouldComponentUpdate, etc
const renderItem = ({item}: {item: any}) => { const renderItem = ({item}: {item: any}) => {
if (item === COMPOSE_PROMPT_ITEM) { if (item === EMPTY_FEED_ITEM) {
return <PromptButtons onPressCompose={onPressCompose} />
} else if (item === EMPTY_FEED_ITEM) {
return ( return (
<EmptyState <EmptyState
icon="bars" icon="bars"
@@ -59,6 +56,9 @@ export const Feed = observer(function Feed({
style={styles.emptyState} style={styles.emptyState}
/> />
) )
}
if (item === HEADER_SPACER_ITEM) {
return <View style={styles.headerSpacer} />
} else { } else {
return <FeedItem item={item} /> return <FeedItem item={item} />
} }
@@ -77,12 +77,15 @@ export const Feed = observer(function Feed({
.loadMore() .loadMore()
.catch(err => feed.rootStore.log.error('Failed to load more posts', err)) .catch(err => feed.rootStore.log.error('Failed to load more posts', err))
} }
let data let data = []
if (headerSpacer) {
data.push(HEADER_SPACER_ITEM)
}
if (feed.hasLoaded) { if (feed.hasLoaded) {
if (feed.isEmpty) { if (feed.isEmpty) {
data = [COMPOSE_PROMPT_ITEM, EMPTY_FEED_ITEM] data.push(EMPTY_FEED_ITEM)
} else { } else {
data = [COMPOSE_PROMPT_ITEM].concat(feed.feed) data = data.concat(feed.feed)
} }
} }
const FeedFooter = () => const FeedFooter = () =>
@@ -95,7 +98,6 @@ export const Feed = observer(function Feed({
) )
return ( return (
<View testID={testID} style={style}> <View testID={testID} style={style}>
{!data && <PromptButtons onPressCompose={onPressCompose} />}
{feed.isLoading && !data && <PostFeedLoadingPlaceholder />} {feed.isLoading && !data && <PostFeedLoadingPlaceholder />}
{feed.hasError && ( {feed.hasError && (
<ErrorMessage message={feed.error} onPressTryAgain={onPressTryAgain} /> <ErrorMessage message={feed.error} onPressTryAgain={onPressTryAgain} />
@@ -120,6 +122,7 @@ export const Feed = observer(function Feed({
}) })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
headerSpacer: {height: 42},
feedFooter: {paddingTop: 20}, feedFooter: {paddingTop: 20},
emptyState: {paddingVertical: 40}, emptyState: {paddingVertical: 40},
}) })
-59
View File
@@ -1,59 +0,0 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {Text} from '../util/text/Text'
import {usePalette} from '../../lib/hooks/usePalette'
import {useAnalytics} from '@segment/analytics-react-native'
export function PromptButtons({
onPressCompose,
}: {
onPressCompose: (imagesOpen?: boolean) => void
}) {
const pal = usePalette('default')
const {track} = useAnalytics()
const onPressNewPost = () => {
track('PromptButtons:NewPost')
onPressCompose(false)
}
const onPressSharePhoto = () => {
track('PromptButtons:SharePhoto')
onPressCompose(true)
}
return (
<View style={[pal.view, pal.border, styles.container]}>
<TouchableOpacity
testID="composePromptButton"
onPress={onPressNewPost}
style={[styles.btn, {backgroundColor: pal.colors.backgroundLight}]}>
<Text type="button" style={pal.text}>
New post
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onPressSharePhoto}
style={[styles.btn, {backgroundColor: pal.colors.backgroundLight}]}>
<Text type="button" style={pal.text}>
Share photo
</Text>
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: {
paddingVertical: 12,
paddingHorizontal: 16,
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
},
btn: {
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: 30,
marginRight: 10,
},
})
+59 -5
View File
@@ -1,11 +1,12 @@
import React from 'react' import React from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {StyleSheet, TouchableOpacity, View} from 'react-native' import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {UserAvatar} from './UserAvatar' import {UserAvatar} from './UserAvatar'
import {Text} from './text/Text' import {Text} from './text/Text'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {usePalette} from '../../lib/hooks/usePalette' import {usePalette} from '../../lib/hooks/usePalette'
import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
import {useAnalytics} from '@segment/analytics-react-native' import {useAnalytics} from '@segment/analytics-react-native'
const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10} const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
@@ -13,9 +14,11 @@ const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
export const ViewHeader = observer(function ViewHeader({ export const ViewHeader = observer(function ViewHeader({
title, title,
canGoBack, canGoBack,
hideOnScroll,
}: { }: {
title: string title: string
canGoBack?: boolean canGoBack?: boolean
hideOnScroll?: boolean
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
@@ -31,7 +34,7 @@ export const ViewHeader = observer(function ViewHeader({
canGoBack = store.nav.tab.canGoBack canGoBack = store.nav.tab.canGoBack
} }
return ( return (
<View style={[styles.header, pal.view]}> <Container hideOnScroll={hideOnScroll || false}>
<TouchableOpacity <TouchableOpacity
testID="viewHeaderBackOrMenuBtn" testID="viewHeaderBackOrMenuBtn"
onPress={canGoBack ? onPressBack : onPressMenu} onPress={canGoBack ? onPressBack : onPressMenu}
@@ -57,10 +60,56 @@ export const ViewHeader = observer(function ViewHeader({
{title} {title}
</Text> </Text>
</View> </View>
</View> <View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
</Container>
) )
}) })
const Container = observer(
({
children,
hideOnScroll,
}: {
children: React.ReactNode
hideOnScroll: boolean
}) => {
const store = useStores()
const pal = usePalette('default')
const interp = useAnimatedValue(0)
React.useEffect(() => {
if (store.shell.minimalShellMode) {
Animated.timing(interp, {
toValue: 1,
duration: 100,
useNativeDriver: true,
isInteraction: false,
}).start()
} else {
Animated.timing(interp, {
toValue: 0,
duration: 100,
useNativeDriver: true,
isInteraction: false,
}).start()
}
}, [interp, store.shell.minimalShellMode])
const transform = {
transform: [{translateY: Animated.multiply(interp, -100)}],
}
if (!hideOnScroll) {
return <View style={[styles.header, pal.view]}>{children}</View>
}
return (
<Animated.View
style={[styles.header, pal.view, styles.headerFloating, transform]}>
{children}
</Animated.View>
)
},
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
header: { header: {
flexDirection: 'row', flexDirection: 'row',
@@ -69,11 +118,16 @@ const styles = StyleSheet.create({
paddingTop: 6, paddingTop: 6,
paddingBottom: 6, paddingBottom: 6,
}, },
headerFloating: {
position: 'absolute',
top: 0,
width: '100%',
},
titleContainer: { titleContainer: {
flexDirection: 'row', marginLeft: 'auto',
alignItems: 'baseline',
marginRight: 'auto', marginRight: 'auto',
paddingRight: 10,
}, },
title: { title: {
fontWeight: 'bold', fontWeight: 'bold',
+2 -2
View File
@@ -88,17 +88,17 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
return ( return (
<View style={s.h100pct}> <View style={s.h100pct}>
<ViewHeader title="Bluesky" canGoBack={false} />
<Feed <Feed
testID="homeFeed" testID="homeFeed"
key="default" key="default"
feed={store.me.mainFeed} feed={store.me.mainFeed}
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
style={s.h100pct} style={s.h100pct}
onPressCompose={onPressCompose}
onPressTryAgain={onPressTryAgain} onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll} onScroll={onMainScroll}
headerSpacer
/> />
<ViewHeader title="Bluesky" canGoBack={false} hideOnScroll />
{store.me.mainFeed.hasNewLatest && !store.me.mainFeed.isRefreshing ? ( {store.me.mainFeed.hasNewLatest && !store.me.mainFeed.isRefreshing ? (
<TouchableOpacity <TouchableOpacity
style={[ style={[
+1 -1
View File
@@ -18,7 +18,7 @@ export const ProfileFollows = ({navIdx, visible, params}: ScreenParams) => {
return ( return (
<View> <View>
<ViewHeader title="Followed" /> <ViewHeader title="Following" />
<ProfileFollowsComponent name={name} /> <ProfileFollowsComponent name={name} />
</View> </View>
) )
+45 -44
View File
@@ -20,6 +20,7 @@ import {WhoToFollow} from '../com/discover/WhoToFollow'
import {SuggestedPosts} from '../com/discover/SuggestedPosts' import {SuggestedPosts} from '../com/discover/SuggestedPosts'
import {ProfileCard} from '../com/profile/ProfileCard' import {ProfileCard} from '../com/profile/ProfileCard'
import {usePalette} from '../lib/hooks/usePalette' import {usePalette} from '../lib/hooks/usePalette'
import {useOnMainScroll} from '../lib/hooks/useOnMainScroll'
import {useAnalytics} from '@segment/analytics-react-native' import {useAnalytics} from '@segment/analytics-react-native'
const MENU_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10} const MENU_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
@@ -30,8 +31,9 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
const store = useStores() const store = useStores()
const {track} = useAnalytics() const {track} = useAnalytics()
const scrollElRef = React.useRef<ScrollView>(null) const scrollElRef = React.useRef<ScrollView>(null)
const onMainScroll = useOnMainScroll(store)
const textInput = React.useRef<TextInput>(null) const textInput = React.useRef<TextInput>(null)
const [lastRenderTime, setRenderTime] = React.useState<number>(0) // used to trigger reloads const [lastRenderTime, setRenderTime] = React.useState<number>(Date.now()) // used to trigger reloads
const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
const [query, setQuery] = React.useState<string>('') const [query, setQuery] = React.useState<string>('')
const autocompleteView = React.useMemo<UserAutocompleteViewModel>( const autocompleteView = React.useMemo<UserAutocompleteViewModel>(
@@ -52,7 +54,7 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
if (visible) { if (visible) {
const now = Date.now() const now = Date.now()
if (lastRenderTime - now > FIVE_MIN) { if (now - lastRenderTime > FIVE_MIN) {
setRenderTime(Date.now()) // trigger reload of suggestions setRenderTime(Date.now()) // trigger reload of suggestions
} }
store.shell.setMinimalShellMode(false) store.shell.setMinimalShellMode(false)
@@ -83,7 +85,12 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
return ( return (
<TouchableWithoutFeedback onPress={Keyboard.dismiss}> <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={[pal.view, styles.container]}> <ScrollView
ref={scrollElRef}
testID="searchScrollView"
style={[pal.view, styles.container]}
onScroll={onMainScroll}
scrollEventThrottle={100}>
<View style={[pal.view, pal.border, styles.header]}> <View style={[pal.view, pal.border, styles.header]}>
<TouchableOpacity <TouchableOpacity
testID="viewHeaderBackOrMenuBtn" testID="viewHeaderBackOrMenuBtn"
@@ -128,40 +135,38 @@ export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
</View> </View>
) : undefined} ) : undefined}
</View> </View>
<View style={styles.outputContainer}> {query && autocompleteView.searchRes.length ? (
{query && autocompleteView.searchRes.length ? ( <>
<ScrollView testID="searchScrollView" onScroll={Keyboard.dismiss}> {autocompleteView.searchRes.map(item => (
{autocompleteView.searchRes.map(item => ( <ProfileCard
<ProfileCard key={item.did}
key={item.did} handle={item.handle}
handle={item.handle} displayName={item.displayName}
displayName={item.displayName} avatar={item.avatar}
avatar={item.avatar} />
/> ))}
))} </>
<View style={s.footerSpacer} /> ) : query && !autocompleteView.searchRes.length ? (
</ScrollView> <View>
) : query && !autocompleteView.searchRes.length ? ( <Text style={[pal.textLight, styles.searchPrompt]}>
<View> No results found for {autocompleteView.prefix}
<Text style={[pal.textLight, styles.searchPrompt]}> </Text>
No results found for {autocompleteView.prefix} </View>
</Text> ) : isInputFocused ? (
</View> <View>
) : isInputFocused ? ( <Text style={[pal.textLight, styles.searchPrompt]}>
<View> Search for users on the network
<Text style={[pal.textLight, styles.searchPrompt]}> </Text>
Search for users on the network </View>
</Text> ) : (
</View> <ScrollView onScroll={Keyboard.dismiss}>
) : ( <WhoToFollow key={`wtf-${lastRenderTime}`} />
<ScrollView onScroll={Keyboard.dismiss} ref={scrollElRef}> <SuggestedPosts key={`sp-${lastRenderTime}`} />
<WhoToFollow key={`wtf-${lastRenderTime}`} /> <View style={s.footerSpacer} />
<SuggestedPosts key={`sp-${lastRenderTime}`} /> </ScrollView>
<View style={s.footerSpacer} /> )}
</ScrollView> <View style={s.footerSpacer} />
)} </ScrollView>
</View>
</View>
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
) )
}) })
@@ -176,7 +181,7 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 12, paddingHorizontal: 12,
paddingTop: 4, paddingTop: 4,
paddingBottom: 5, marginBottom: 14,
}, },
headerMenuBtn: { headerMenuBtn: {
width: 40, width: 40,
@@ -189,7 +194,7 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
borderRadius: 30, borderRadius: 30,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 6, paddingVertical: 8,
}, },
headerSearchIcon: { headerSearchIcon: {
marginRight: 6, marginRight: 6,
@@ -197,7 +202,7 @@ const styles = StyleSheet.create({
}, },
headerSearchInput: { headerSearchInput: {
flex: 1, flex: 1,
fontSize: 16, fontSize: 17,
}, },
headerCancelBtn: { headerCancelBtn: {
width: 60, width: 60,
@@ -208,8 +213,4 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
paddingTop: 10, paddingTop: 10,
}, },
outputContainer: {
flex: 1,
},
}) })