Add custom feeds selector, rework search, simplify onboarding (#325)

* Get home screen's swipable pager working with the drawer

* Add tab bar to pager

* Implement popular & following views on home screen

* Visual tune-up

* Move the feed selector to the footer

* Fix to 'new posts' poll

* Add the view header as a feed item

* Use the native driver on the tabbar indicator to improve perf

* Reduce home polling to the currently active page; also reuse some code

* Add soft reset on tap selected in tab bar

* Remove explicit 'onboarding' flow

* Choose good stuff based on service

* Add foaf-based follow discovery

* Fall back to who to follow

* Fix backgrounds

* Switch to the off-spec goodstuff route

* 1.8

* Fix for dev & staging

* Swap the tab bar items and rename suggested to what's hot

* Go to whats-hot by default if you have no follows

* Implement pager and tabbar for desktop web

* Pin deps to make expo happy

* Add language filtering to goodstuff
This commit is contained in:
Paul Frazee 2023-03-19 18:53:57 -05:00 committed by GitHub
parent c31ffdac1b
commit 1de724b24b
33 changed files with 1634 additions and 692 deletions

View file

@ -1,116 +1,68 @@
import React from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {CenteredView, FlatList} from '../util/Views'
import {observer} from 'mobx-react-lite'
import {ErrorScreen} from '../util/error/ErrorScreen'
import {StyleSheet, View} from 'react-native'
import {AppBskyActorRef, AppBskyActorProfile} from '@atproto/api'
import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs'
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {useStores} from 'state/index'
import {
SuggestedActorsViewModel,
SuggestedActor,
} from 'state/models/suggested-actors-view'
import {s} from 'lib/styles'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
export const SuggestedFollows = observer(
({onNoSuggestions}: {onNoSuggestions?: () => void}) => {
const pal = usePalette('default')
const store = useStores()
const view = React.useMemo<SuggestedActorsViewModel>(
() => new SuggestedActorsViewModel(store),
[store],
)
React.useEffect(() => {
view
.loadMore()
.catch((err: any) =>
store.log.error('Failed to fetch suggestions', err),
)
}, [view, store.log])
React.useEffect(() => {
if (!view.isLoading && !view.hasError && !view.hasContent) {
onNoSuggestions?.()
}
}, [view, view.isLoading, view.hasError, view.hasContent, onNoSuggestions])
const onRefresh = () => {
view
.refresh()
.catch((err: any) =>
store.log.error('Failed to fetch suggestions', err),
)
}
const onEndReached = () => {
view
.loadMore()
.catch(err =>
view?.rootStore.log.error('Failed to load more suggestions', err),
)
}
const renderItem = ({item}: {item: SuggestedActor}) => {
return (
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
description={item.description}
/>
)
}
return (
<View style={styles.container}>
{view.hasError ? (
<CenteredView>
<ErrorScreen
title="Failed to load suggestions"
message="There was an error while trying to load suggested follows."
details={view.error}
onPressTryAgain={onRefresh}
/>
</CenteredView>
) : view.isEmpty ? (
<View />
) : (
<View style={[styles.suggestionsContainer, pal.view]}>
<FlatList
data={view.suggestions}
keyExtractor={item => item.did}
refreshing={view.isRefreshing}
onRefresh={onRefresh}
onEndReached={onEndReached}
renderItem={renderItem}
initialNumToRender={15}
ListFooterComponent={() => (
<View style={styles.footer}>
{view.isLoading && <ActivityIndicator />}
</View>
)}
contentContainerStyle={s.contentContainer}
/>
</View>
)}
</View>
)
},
)
export const SuggestedFollows = ({
title,
suggestions,
}: {
title: string
suggestions: (AppBskyActorRef.WithInfo | RefWithInfoAndFollowers)[]
}) => {
const pal = usePalette('default')
return (
<View style={[styles.container, pal.view]}>
<Text type="title" style={[styles.heading, pal.text]}>
{title}
</Text>
{suggestions.map(item => (
<View key={item.did} style={[styles.card, pal.view, pal.border]}>
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
noBg
noBorder
description=""
followers={
item.followers
? (item.followers as AppBskyActorProfile.View[])
: undefined
}
/>
</View>
))}
</View>
)
}
const styles = StyleSheet.create({
container: {
height: '100%',
paddingVertical: 10,
paddingHorizontal: 4,
},
suggestionsContainer: {
height: '100%',
heading: {
fontWeight: 'bold',
paddingHorizontal: 4,
paddingBottom: 8,
},
footer: {
height: 200,
paddingTop: 20,
card: {
borderRadius: 12,
marginBottom: 2,
borderWidth: 1,
},
loadMore: {
paddingLeft: 16,
paddingVertical: 12,
},
})

View file

@ -7,23 +7,17 @@ import {
StyleSheet,
ViewStyle,
} from 'react-native'
import {useNavigation} from '@react-navigation/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 {Text} from '../util/text/Text'
import {ViewHeader} from '../util/ViewHeader'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Button} from '../util/forms/Button'
import {FeedModel} from 'state/models/feed-view'
import {FeedSlice} from './FeedSlice'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles'
import {useAnalytics} from 'lib/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {MagnifyingGlassIcon} from 'lib/icons'
import {NavigationProp} from 'lib/routes/types'
const HEADER_ITEM = {_reactKey: '__header__'}
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
const ERROR_FEED_ITEM = {_reactKey: '__error__'}
@ -34,6 +28,7 @@ export const Feed = observer(function Feed({
scrollElRef,
onPressTryAgain,
onScroll,
renderEmptyState,
testID,
headerOffset = 0,
}: {
@ -43,17 +38,15 @@ export const Feed = observer(function Feed({
scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressTryAgain?: () => void
onScroll?: OnScrollCb
renderEmptyState?: () => JSX.Element
testID?: string
headerOffset?: number
}) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const {track} = useAnalytics()
const [isRefreshing, setIsRefreshing] = React.useState(false)
const navigation = useNavigation<NavigationProp>()
const data = React.useMemo(() => {
let feedItems: any[] = []
let feedItems: any[] = [HEADER_ITEM]
if (feed.hasLoaded) {
if (feed.hasError) {
feedItems = feedItems.concat([ERROR_FEED_ITEM])
@ -80,6 +73,7 @@ export const Feed = observer(function Feed({
}
setIsRefreshing(false)
}, [feed, track, setIsRefreshing])
const onEndReached = React.useCallback(async () => {
track('Feed:onEndReached')
try {
@ -95,37 +89,10 @@ export const Feed = observer(function Feed({
const renderItem = React.useCallback(
({item}: {item: any}) => {
if (item === EMPTY_FEED_ITEM) {
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 feed is empty! You should follow some accounts to fix this.
</Text>
<Button
type="inverted"
style={styles.emptyBtn}
onPress={
() =>
navigation.navigate(
'SearchTab',
) /* TODO make sure it goes to root of the tab */
}>
<Text type="lg-medium" style={palInverted.text}>
Find accounts
</Text>
<FontAwesomeIcon
icon="angle-right"
style={palInverted.text as FontAwesomeIconStyle}
size={14}
/>
</Button>
</View>
)
if (renderEmptyState) {
return renderEmptyState()
}
return <View />
} else if (item === ERROR_FEED_ITEM) {
return (
<ErrorMessage
@ -133,10 +100,12 @@ export const Feed = observer(function Feed({
onPressTryAgain={onPressTryAgain}
/>
)
} else if (item === HEADER_ITEM) {
return <ViewHeader title="Bluesky" canGoBack={false} />
}
return <FeedSlice slice={item} showFollowBtn={showPostFollowBtn} />
},
[feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, navigation],
[feed, onPressTryAgain, showPostFollowBtn, renderEmptyState],
)
const FeedFooter = React.useCallback(
@ -183,21 +152,4 @@ export const Feed = observer(function Feed({
const styles = StyleSheet.create({
feedFooter: {paddingTop: 20},
emptyContainer: {
paddingVertical: 40,
paddingHorizontal: 30,
},
emptyIconContainer: {
marginBottom: 16,
},
emptyIcon: {
marginLeft: 'auto',
marginRight: 'auto',
},
emptyBtn: {
marginTop: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
})

View file

@ -0,0 +1,81 @@
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 {MagnifyingGlassIcon} from 'lib/icons'
import {NavigationProp} from 'lib/routes/types'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
export function FollowingEmptyState() {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const navigation = useNavigation<NavigationProp>()
const onPressFindAccounts = React.useCallback(() => {
navigation.navigate('SearchTab')
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
</Text>
<FontAwesomeIcon
icon="angle-right"
style={palInverted.text as FontAwesomeIconStyle}
size={14}
/>
</Button>
</View>
)
}
const styles = StyleSheet.create({
emptyContainer: {
// flex: 1,
height: '100%',
paddingVertical: 40,
paddingHorizontal: 30,
},
emptyIconContainer: {
marginBottom: 16,
},
emptyIcon: {
marginLeft: 'auto',
marginRight: 'auto',
},
emptyBtn: {
marginVertical: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 18,
paddingHorizontal: 24,
borderRadius: 30,
},
feedsTip: {
position: 'absolute',
left: 22,
},
feedsTipArrow: {
marginLeft: 32,
marginTop: 8,
},
})

View file

@ -1,16 +1,18 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {Button} from '../util/forms/Button'
import {Button, ButtonType} from '../util/forms/Button'
import {useStores} from 'state/index'
import * as apilib from 'lib/api/index'
import * as Toast from '../util/Toast'
const FollowButton = observer(
({
type = 'inverted',
did,
declarationCid,
onToggleFollow,
}: {
type?: ButtonType
did: string
declarationCid: string
onToggleFollow?: (v: boolean) => void
@ -42,7 +44,7 @@ const FollowButton = observer(
return (
<Button
type={isFollowing ? 'default' : 'primary'}
type={isFollowing ? 'default' : type}
onPress={onToggleFollowInner}
label={isFollowing ? 'Unfollow' : 'Follow'}
/>

View file

@ -1,6 +1,7 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {AppBskyActorProfile} from '@atproto/api'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
@ -15,7 +16,9 @@ export function ProfileCard({
avatar,
description,
isFollowedBy,
noBg,
noBorder,
followers,
renderButton,
}: {
handle: string
@ -23,7 +26,9 @@ export function ProfileCard({
avatar?: string
description?: string
isFollowedBy?: boolean
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorProfile.View[] | undefined
renderButton?: () => JSX.Element
}) {
const pal = usePalette('default')
@ -31,9 +36,9 @@ export function ProfileCard({
<Link
style={[
styles.outer,
pal.view,
pal.border,
noBorder && styles.outerNoBorder,
!noBg && pal.view,
]}
href={`/profile/${handle}`}
title={handle}
@ -73,6 +78,25 @@ export function ProfileCard({
</Text>
</View>
) : undefined}
{followers?.length ? (
<View style={styles.followedBy}>
<Text
type="sm"
style={[styles.followsByDesc, pal.textLight]}
numberOfLines={2}
lineHeight={1.2}>
Followed by{' '}
{followers.map(f => f.displayName || f.handle).join(', ')}
</Text>
{followers.slice(0, 3).map(f => (
<View key={f.did} style={styles.followedByAviContainer}>
<View style={[styles.followedByAvi, pal.view]}>
<UserAvatar avatar={f.avatar} size={32} />
</View>
</View>
))}
</View>
) : undefined}
</Link>
)
}
@ -86,6 +110,9 @@ export const ProfileCardWithFollowBtn = observer(
avatar,
description,
isFollowedBy,
noBg,
noBorder,
followers,
}: {
did: string
declarationCid: string
@ -94,6 +121,9 @@ export const ProfileCardWithFollowBtn = observer(
avatar?: string
description?: string
isFollowedBy?: boolean
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorProfile.View[] | undefined
}) => {
const store = useStores()
const isMe = store.me.handle === handle
@ -105,6 +135,9 @@ export const ProfileCardWithFollowBtn = observer(
avatar={avatar}
description={description}
isFollowedBy={isFollowedBy}
noBg={noBg}
noBorder={noBorder}
followers={followers}
renderButton={
isMe
? undefined
@ -128,8 +161,8 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
layoutAvi: {
width: 60,
paddingLeft: 10,
width: 54,
paddingLeft: 4,
paddingTop: 8,
paddingBottom: 10,
},
@ -164,4 +197,27 @@ const styles = StyleSheet.create({
marginLeft: 6,
paddingHorizontal: 14,
},
followedBy: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingLeft: 54,
paddingRight: 20,
marginBottom: 10,
marginTop: -6,
},
followedByAviContainer: {
width: 24,
height: 36,
},
followedByAvi: {
width: 36,
height: 36,
borderRadius: 18,
padding: 2,
},
followsByDesc: {
flex: 1,
paddingRight: 10,
},
})

View file

@ -128,6 +128,46 @@ export function NotificationFeedLoadingPlaceholder() {
)
}
export function ProfileCardLoadingPlaceholder({
style,
}: {
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
return (
<View style={[styles.profileCard, pal.view, style]}>
<LoadingPlaceholder
width={40}
height={40}
style={styles.profileCardAvi}
/>
<View>
<LoadingPlaceholder width={140} height={8} style={[s.mb5]} />
<LoadingPlaceholder width={120} height={8} style={[s.mb10]} />
<LoadingPlaceholder width={220} height={8} style={[s.mb5]} />
</View>
</View>
)
}
export function ProfileCardFeedLoadingPlaceholder() {
return (
<>
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
</>
)
}
const styles = StyleSheet.create({
loadingPlaceholder: {
borderRadius: 6,
@ -147,6 +187,15 @@ const styles = StyleSheet.create({
paddingLeft: 46,
margin: 1,
},
profileCard: {
flexDirection: 'row',
padding: 10,
margin: 1,
},
profileCardAvi: {
borderRadius: 20,
marginRight: 10,
},
smallAvatar: {
borderRadius: 15,
marginRight: 10,

View file

@ -44,7 +44,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
// two-liner with follow button
return (
<View style={styles.metaTwoLine}>
<View>
<View style={styles.metaTwoLineLeft}>
<View style={styles.metaTwoLineTop}>
<DesktopWebTextLink
type="lg-bold"
@ -69,6 +69,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
type="md"
style={[styles.metaItem, pal.textLight]}
lineHeight={1.2}
numberOfLines={1}
text={`@${handle}`}
href={`/profile/${opts.authorHandle}`}
/>
@ -76,6 +77,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
<View>
<FollowButton
type="default"
did={opts.did}
declarationCid={opts.declarationCid}
onToggleFollow={onToggleFollow}
@ -134,7 +136,12 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 2,
width: '100%',
paddingBottom: 4,
},
metaTwoLineLeft: {
flex: 1,
paddingRight: 40,
},
metaTwoLineTop: {
flexDirection: 'row',

View file

@ -0,0 +1,162 @@
import React, {createRef, useState, useMemo} from 'react'
import {
Animated,
StyleSheet,
TouchableWithoutFeedback,
View,
} from 'react-native'
import {Text} from './text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {isDesktopWeb} from 'platform/detection'
interface Layout {
x: number
width: number
}
export interface TabBarProps {
selectedPage: number
items: string[]
position: Animated.Value
offset: Animated.Value
indicatorPosition?: 'top' | 'bottom'
indicatorColor?: string
onSelect?: (index: number) => void
onPressSelected?: () => void
}
export function TabBar({
selectedPage,
items,
position,
offset,
indicatorPosition = 'bottom',
indicatorColor,
onSelect,
onPressSelected,
}: TabBarProps) {
const pal = usePalette('default')
const [itemLayouts, setItemLayouts] = useState<Layout[]>(
items.map(() => ({x: 0, width: 0})),
)
const itemRefs = useMemo(
() => Array.from({length: items.length}).map(() => createRef<View>()),
[items.length],
)
const panX = Animated.add(position, offset)
const indicatorStyle = {
backgroundColor: indicatorColor || pal.colors.link,
bottom:
indicatorPosition === 'bottom' ? (isDesktopWeb ? 0 : -1) : undefined,
top: indicatorPosition === 'top' ? (isDesktopWeb ? 0 : -1) : undefined,
transform: [
{
translateX: panX.interpolate({
inputRange: items.map((_item, i) => i),
outputRange: itemLayouts.map(l => l.x + l.width / 2),
}),
},
{
scaleX: panX.interpolate({
inputRange: items.map((_item, i) => i),
outputRange: itemLayouts.map(l => l.width),
}),
},
],
}
const onLayout = () => {
const promises = []
for (let i = 0; i < items.length; i++) {
promises.push(
new Promise<Layout>(resolve => {
itemRefs[i].current?.measure(
(x: number, _y: number, width: number) => {
resolve({x, width})
},
)
}),
)
}
Promise.all(promises).then((layouts: Layout[]) => {
setItemLayouts(layouts)
})
}
const onPressItem = (index: number) => {
onSelect?.(index)
if (index === selectedPage) {
onPressSelected?.()
}
}
return (
<View style={[pal.view, styles.outer]} onLayout={onLayout}>
<Animated.View style={[styles.indicator, indicatorStyle]} />
{items.map((item, i) => {
const selected = i === selectedPage
return (
<TouchableWithoutFeedback key={i} onPress={() => onPressItem(i)}>
<View
style={
indicatorPosition === 'top' ? styles.itemTop : styles.itemBottom
}
ref={itemRefs[i]}>
<Text type="xl-bold" style={selected ? pal.text : pal.textLight}>
{item}
</Text>
</View>
</TouchableWithoutFeedback>
)
})}
</View>
)
}
const styles = isDesktopWeb
? StyleSheet.create({
outer: {
flexDirection: 'row',
paddingHorizontal: 18,
},
itemTop: {
paddingTop: 16,
paddingBottom: 14,
marginRight: 24,
},
itemBottom: {
paddingTop: 14,
paddingBottom: 16,
marginRight: 24,
},
indicator: {
position: 'absolute',
left: 0,
width: 1,
height: 3,
},
})
: StyleSheet.create({
outer: {
flexDirection: 'row',
paddingHorizontal: 14,
},
itemTop: {
paddingTop: 10,
paddingBottom: 10,
marginRight: 24,
},
itemBottom: {
paddingTop: 8,
paddingBottom: 12,
marginRight: 24,
},
indicator: {
position: 'absolute',
left: 0,
width: 1,
height: 3,
borderRadius: 4,
},
})

View file

@ -1,101 +0,0 @@
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'
import {CenteredView} from './Views'
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 (
<CenteredView
testID="welcomeBanner"
style={[pal.view, styles.container, pal.border]}>
<Text
type="title-lg"
style={[pal.text, s.textCenter, s.bold, s.pb5]}
lineHeight={1.1}>
Welcome to Bluesky!
</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>
</>
)}
</CenteredView>
)
})
const styles = StyleSheet.create({
container: {
paddingTop: 16,
paddingBottom: 16,
paddingHorizontal: 20,
borderTopWidth: 1,
borderBottomWidth: 1,
},
controls: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 10,
},
progress: {
marginTop: 12,
},
})

View file

@ -0,0 +1,87 @@
import React from 'react'
import {Animated, View} from 'react-native'
import PagerView, {PagerViewOnPageSelectedEvent} from 'react-native-pager-view'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {s} from 'lib/styles'
export type PageSelectedEvent = PagerViewOnPageSelectedEvent
const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
export interface RenderTabBarFnProps {
selectedPage: number
position: Animated.Value
offset: Animated.Value
onSelect?: (index: number) => void
}
export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
interface Props {
tabBarPosition?: 'top' | 'bottom'
initialPage?: number
renderTabBar: RenderTabBarFn
onPageSelected?: (index: number) => void
}
export const Pager = ({
children,
tabBarPosition = 'top',
initialPage = 0,
renderTabBar,
onPageSelected,
}: React.PropsWithChildren<Props>) => {
const [selectedPage, setSelectedPage] = React.useState(0)
const position = useAnimatedValue(0)
const offset = useAnimatedValue(0)
const pagerView = React.useRef<PagerView>()
const onPageSelectedInner = React.useCallback(
(e: PageSelectedEvent) => {
setSelectedPage(e.nativeEvent.position)
onPageSelected?.(e.nativeEvent.position)
},
[setSelectedPage, onPageSelected],
)
const onTabBarSelect = React.useCallback(
(index: number) => {
pagerView.current?.setPage(index)
},
[pagerView],
)
return (
<View>
{tabBarPosition === 'top' &&
renderTabBar({
selectedPage,
position,
offset,
onSelect: onTabBarSelect,
})}
<AnimatedPagerView
ref={pagerView}
style={s.h100pct}
initialPage={initialPage}
onPageSelected={onPageSelectedInner}
onPageScroll={Animated.event(
[
{
nativeEvent: {
position: position,
offset: offset,
},
},
],
{useNativeDriver: true},
)}>
{children}
</AnimatedPagerView>
{tabBarPosition === 'bottom' &&
renderTabBar({
selectedPage,
position,
offset,
onSelect: onTabBarSelect,
})}
</View>
)
}

View file

@ -0,0 +1,69 @@
import React from 'react'
import {Animated, View} from 'react-native'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {s} from 'lib/styles'
export interface RenderTabBarFnProps {
selectedPage: number
position: Animated.Value
offset: Animated.Value
onSelect?: (index: number) => void
}
export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
interface Props {
tabBarPosition?: 'top' | 'bottom'
initialPage?: number
renderTabBar: RenderTabBarFn
onPageSelected?: (index: number) => void
}
export const Pager = ({
children,
tabBarPosition = 'top',
initialPage = 0,
renderTabBar,
onPageSelected,
}: React.PropsWithChildren<Props>) => {
const [selectedPage, setSelectedPage] = React.useState(initialPage)
const position = useAnimatedValue(0)
const offset = useAnimatedValue(0)
const onTabBarSelect = React.useCallback(
(index: number) => {
setSelectedPage(index)
onPageSelected?.(index)
Animated.timing(position, {
toValue: index,
duration: 200,
useNativeDriver: true,
}).start()
},
[setSelectedPage, onPageSelected, position],
)
return (
<View>
{tabBarPosition === 'top' &&
renderTabBar({
selectedPage,
position,
offset,
onSelect: onTabBarSelect,
})}
{children.map((child, i) => (
<View
style={selectedPage === i ? undefined : s.hidden}
key={`page-${i}`}>
{child}
</View>
))}
{tabBarPosition === 'bottom' &&
renderTabBar({
selectedPage,
position,
offset,
onSelect: onTabBarSelect,
})}
</View>
)
}