diff options
Diffstat (limited to 'src/view/com')
-rw-r--r-- | src/view/com/discover/SuggestedFollows.tsx | 160 | ||||
-rw-r--r-- | src/view/com/posts/Feed.tsx | 74 | ||||
-rw-r--r-- | src/view/com/posts/FollowingEmptyState.tsx | 81 | ||||
-rw-r--r-- | src/view/com/profile/FollowButton.tsx | 6 | ||||
-rw-r--r-- | src/view/com/profile/ProfileCard.tsx | 62 | ||||
-rw-r--r-- | src/view/com/util/LoadingPlaceholder.tsx | 49 | ||||
-rw-r--r-- | src/view/com/util/PostMeta.tsx | 11 | ||||
-rw-r--r-- | src/view/com/util/TabBar.tsx | 162 | ||||
-rw-r--r-- | src/view/com/util/WelcomeBanner.tsx | 101 | ||||
-rw-r--r-- | src/view/com/util/pager/Pager.tsx | 87 | ||||
-rw-r--r-- | src/view/com/util/pager/Pager.web.tsx | 69 |
11 files changed, 589 insertions, 273 deletions
diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx index 1e40956ce..7a64a15f6 100644 --- a/src/view/com/discover/SuggestedFollows.tsx +++ b/src/view/com/discover/SuggestedFollows.tsx @@ -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, + }, + + heading: { + fontWeight: 'bold', + paddingHorizontal: 4, + paddingBottom: 8, }, - suggestionsContainer: { - height: '100%', + card: { + borderRadius: 12, + marginBottom: 2, + borderWidth: 1, }, - footer: { - height: 200, - paddingTop: 20, + + loadMore: { + paddingLeft: 16, + paddingVertical: 12, }, }) diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 1edcd55d9..c910b70e7 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -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', - }, }) diff --git a/src/view/com/posts/FollowingEmptyState.tsx b/src/view/com/posts/FollowingEmptyState.tsx new file mode 100644 index 000000000..acd035f21 --- /dev/null +++ b/src/view/com/posts/FollowingEmptyState.tsx @@ -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, + }, +}) diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index f24c3d0c9..5204f5a40 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -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'} /> diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 087536c36..53f45fb11 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -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, + }, }) diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx index 9e72640d2..2f653ee09 100644 --- a/src/view/com/util/LoadingPlaceholder.tsx +++ b/src/view/com/util/LoadingPlaceholder.tsx @@ -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, diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index 0bb402100..c53de5c1f 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -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', diff --git a/src/view/com/util/TabBar.tsx b/src/view/com/util/TabBar.tsx new file mode 100644 index 000000000..4b67b8a80 --- /dev/null +++ b/src/view/com/util/TabBar.tsx @@ -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, + }, + }) diff --git a/src/view/com/util/WelcomeBanner.tsx b/src/view/com/util/WelcomeBanner.tsx deleted file mode 100644 index 428a30764..000000000 --- a/src/view/com/util/WelcomeBanner.tsx +++ /dev/null @@ -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, - }, -}) diff --git a/src/view/com/util/pager/Pager.tsx b/src/view/com/util/pager/Pager.tsx new file mode 100644 index 000000000..416828a27 --- /dev/null +++ b/src/view/com/util/pager/Pager.tsx @@ -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> + ) +} diff --git a/src/view/com/util/pager/Pager.web.tsx b/src/view/com/util/pager/Pager.web.tsx new file mode 100644 index 000000000..3c2805833 --- /dev/null +++ b/src/view/com/util/pager/Pager.web.tsx @@ -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> + ) +} |