Add feeds tab

This commit is contained in:
Paul Frazee 2023-05-25 20:02:37 -05:00
parent df6d249e85
commit 257686f360
17 changed files with 937 additions and 290 deletions

View file

@ -8,7 +8,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {SatelliteDishIcon} from 'lib/icons'
import {CogIcon} from 'lib/icons'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {s} from 'lib/styles'
@ -69,11 +69,7 @@ export const FeedsTabBar = observer(
accessibilityRole="button"
accessibilityLabel="Edit Saved Feeds"
accessibilityHint="Opens screen to edit Saved Feeds">
<SatelliteDishIcon
size={20}
strokeWidth={2}
style={pal.textLight}
/>
<CogIcon size={21} strokeWidth={2} style={pal.textLight} />
</Link>
</View>
</View>

View file

@ -131,7 +131,7 @@ const styles = isDesktopWeb
backgroundColor: 'transparent',
},
contentContainer: {
columnGap: 16,
columnGap: 20,
marginLeft: 18,
paddingRight: 28,
backgroundColor: 'transparent',

View file

@ -33,7 +33,6 @@ export const Feed = observer(function Feed({
onPressTryAgain,
onScroll,
scrollEventThrottle,
onMomentumScrollEnd,
renderEmptyState,
testID,
headerOffset = 0,
@ -186,7 +185,6 @@ export const Feed = observer(function Feed({
style={{paddingTop: headerOffset}}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
onMomentumScrollEnd={onMomentumScrollEnd}
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
onEndReached={onEndReached}
onEndReachedThreshold={0.6}

View file

@ -0,0 +1,230 @@
import React, {MutableRefObject} from 'react'
import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
RefreshControl,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FlatList} from '../util/Views'
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {PostsMultiFeedModel, MultiFeedItem} from 'state/models/feeds/multi-feed'
import {FeedSlice} from './FeedSlice'
import {Text} from '../util/text/Text'
import {Link} from '../util/Link'
import {UserAvatar} from '../util/UserAvatar'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles'
import {useAnalytics} from 'lib/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
export const MultiFeed = observer(function Feed({
multifeed,
style,
showPostFollowBtn,
scrollElRef,
onScroll,
scrollEventThrottle,
testID,
headerOffset = 0,
extraData,
}: {
multifeed: PostsMultiFeedModel
style?: StyleProp<ViewStyle>
showPostFollowBtn?: boolean
scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressTryAgain?: () => void
onScroll?: OnScrollCb
scrollEventThrottle?: number
renderEmptyState?: () => JSX.Element
testID?: string
headerOffset?: number
extraData?: any
}) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const theme = useTheme()
const {track} = useAnalytics()
const [isRefreshing, setIsRefreshing] = React.useState(false)
// events
// =
const onRefresh = React.useCallback(async () => {
track('MultiFeed:onRefresh')
setIsRefreshing(true)
try {
await multifeed.refresh()
} catch (err) {
multifeed.rootStore.log.error('Failed to refresh posts feed', err)
}
setIsRefreshing(false)
}, [multifeed, track, setIsRefreshing])
const onEndReached = React.useCallback(async () => {
track('MultiFeed:onEndReached')
try {
await multifeed.loadMore()
} catch (err) {
multifeed.rootStore.log.error('Failed to load more posts', err)
}
}, [multifeed, track])
// rendering
// =
const renderItem = React.useCallback(
({item}: {item: MultiFeedItem}) => {
if (item.type === 'header') {
return <View style={[styles.header, pal.border]} />
} else if (item.type === 'feed-header') {
return (
<View style={styles.feedHeader}>
<UserAvatar type="algo" avatar={item.avatar} size={28} />
<Text type="title-lg" style={[pal.text, styles.feedHeaderTitle]}>
{item.title}
</Text>
</View>
)
} else if (item.type === 'feed-slice') {
return (
<FeedSlice slice={item.slice} showFollowBtn={showPostFollowBtn} />
)
} else if (item.type === 'feed-loading') {
return <PostFeedLoadingPlaceholder />
} else if (item.type === 'feed-error') {
return <ErrorMessage message={item.error} />
} else if (item.type === 'feed-footer') {
return (
<Link
href={item.uri}
style={[styles.feedFooter, pal.border, pal.view]}>
<Text type="lg" style={pal.link}>
See more from {item.title}
</Text>
<FontAwesomeIcon
icon="angle-right"
size={18}
color={pal.colors.link}
/>
</Link>
)
} else if (item.type === 'footer') {
return (
<Link
style={[styles.footerLink, palInverted.view]}
href="/search/feeds">
<FontAwesomeIcon
icon="search"
size={18}
color={palInverted.colors.text}
/>
<Text type="lg-medium" style={palInverted.text}>
Discover new feeds
</Text>
</Link>
)
}
return null
},
[showPostFollowBtn, pal, palInverted],
)
const FeedFooter = React.useCallback(
() =>
multifeed.isLoading && !isRefreshing ? (
<View style={styles.loadMore}>
<ActivityIndicator color={pal.colors.text} />
</View>
) : (
<View />
),
[multifeed.isLoading, isRefreshing, pal],
)
return (
<View testID={testID} style={style}>
{multifeed.items.length > 0 && (
<FlatList
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={multifeed.items}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
ListFooterComponent={FeedFooter}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={s.contentContainer}
style={[{paddingTop: headerOffset}, pal.viewLight, styles.container]}
onScroll={onScroll}
scrollEventThrottle={scrollEventThrottle}
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
onEndReached={onEndReached}
onEndReachedThreshold={0.6}
removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}
extraData={extraData}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)}
</View>
)
})
const styles = StyleSheet.create({
container: {
height: '100%',
},
header: {
borderTopWidth: 1,
marginBottom: 4,
},
feedHeader: {
flexDirection: 'row',
gap: 8,
alignItems: 'center',
paddingHorizontal: 16,
paddingBottom: 8,
marginTop: 12,
},
feedHeaderTitle: {
fontWeight: 'bold',
},
feedFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 16,
marginBottom: 12,
borderTopWidth: 1,
borderBottomWidth: 1,
},
footerLink: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 12,
marginHorizontal: 8,
marginBottom: 8,
gap: 8,
},
loadMore: {
paddingTop: 10,
},
})

125
src/view/screens/Feeds.tsx Normal file
View file

@ -0,0 +1,125 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import isEqual from 'lodash.isequal'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {FlatList} from 'view/com/util/Views'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {FAB} from 'view/com/util/fab/FAB'
import {Link} from 'view/com/util/Link'
import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types'
import {observer} from 'mobx-react-lite'
import {PostsMultiFeedModel} from 'state/models/feeds/multi-feed'
import {MultiFeed} from 'view/com/posts/MultiFeed'
import {isDesktopWeb} from 'platform/detection'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {ComposeIcon2, CogIcon} from 'lib/icons'
import {s} from 'lib/styles'
const HEADER_OFFSET = isDesktopWeb ? 0 : 40
type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
export const FeedsScreen = withAuthRequired(
observer<Props>(({}: Props) => {
const pal = usePalette('default')
const store = useStores()
const flatListRef = React.useRef<FlatList>(null)
const multifeed = React.useMemo<PostsMultiFeedModel>(
() => new PostsMultiFeedModel(store),
[store],
)
const [onMainScroll, isScrolledDown, resetMainScroll] =
useOnMainScroll(store)
const onSoftReset = React.useCallback(() => {
flatListRef.current?.scrollToOffset({offset: 0})
resetMainScroll()
}, [flatListRef, resetMainScroll])
useFocusEffect(
React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
const multifeedCleanup = multifeed.registerListeners()
const cleanup = () => {
softResetSub.remove()
multifeedCleanup()
}
store.shell.setMinimalShellMode(false)
return cleanup
}, [store, multifeed, onSoftReset]),
)
React.useEffect(() => {
if (
isEqual(
multifeed.feedInfos.map(f => f.uri),
store.me.savedFeeds.all.map(f => f.uri),
)
) {
// no changes
return
}
multifeed.refresh()
}, [multifeed, store.me.savedFeeds.all])
const onPressCompose = React.useCallback(() => {
store.shell.openComposer({})
}, [store])
const renderHeaderBtn = React.useCallback(() => {
return (
<Link
href="/settings/saved-feeds"
hitSlop={10}
accessibilityRole="button"
accessibilityLabel="Edit Saved Feeds"
accessibilityHint="Opens screen to edit Saved Feeds">
<CogIcon size={22} strokeWidth={2} style={pal.textLight} />
</Link>
)
}, [pal])
return (
<View style={[pal.view, styles.container]}>
<MultiFeed
scrollElRef={flatListRef}
multifeed={multifeed}
onScroll={onMainScroll}
scrollEventThrottle={100}
headerOffset={HEADER_OFFSET}
/>
<ViewHeader
title="My Feeds"
canGoBack={false}
hideOnScroll
renderButton={renderHeaderBtn}
/>
{isScrolledDown ? (
<LoadLatestBtn
onPress={onSoftReset}
label="Scroll to top"
showIndicator={false}
/>
) : null}
<FAB
testID="composeFAB"
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel="Compose post"
accessibilityHint=""
/>
</View>
)
}),
)
const styles = StyleSheet.create({
container: {
flex: 1,
},
})

View file

@ -118,7 +118,11 @@ export const SavedFeeds = withAuthRequired(
pal.border,
isDesktopWeb && styles.desktopContainer,
]}>
<ViewHeader title="My Feeds" showOnDesktop showBorder={!isDesktopWeb} />
<ViewHeader
title="Edit My Feeds"
showOnDesktop
showBorder={!isDesktopWeb}
/>
<DraggableFlatList
containerStyle={[!isDesktopWeb && s.flex1]}
data={savedFeeds.all}

View file

@ -30,6 +30,7 @@ import {
MoonIcon,
UserIconSolid,
SatelliteDishIcon,
SatelliteDishIconSolid,
HandIcon,
} from 'lib/icons'
import {UserAvatar} from 'view/com/util/UserAvatar'
@ -50,7 +51,7 @@ export const DrawerContent = observer(() => {
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics()
const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} =
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
useNavigationTabState()
const {notifications} = store.me
@ -97,11 +98,10 @@ export const DrawerContent = observer(() => {
onPressTab('MyProfile')
}, [onPressTab])
const onPressMyFeeds = React.useCallback(() => {
track('Menu:ItemClicked', {url: 'MyFeeds'})
navigation.navigate('SavedFeeds')
store.shell.closeDrawer()
}, [navigation, track, store.shell])
const onPressMyFeeds = React.useCallback(
() => onPressTab('Feeds'),
[onPressTab],
)
const onPressModeration = React.useCallback(() => {
track('Menu:ItemClicked', {url: 'Moderation'})
@ -240,11 +240,19 @@ export const DrawerContent = observer(() => {
/>
<MenuItem
icon={
<SatelliteDishIcon
strokeWidth={1.5}
style={pal.text as FontAwesomeIconStyle}
size={24}
/>
isAtFeeds ? (
<SatelliteDishIconSolid
strokeWidth={1.5}
style={pal.text as FontAwesomeIconStyle}
size={24}
/>
) : (
<SatelliteDishIcon
strokeWidth={1.5}
style={pal.text as FontAwesomeIconStyle}
size={24}
/>
)
}
label="My Feeds"
accessibilityLabel="My Feeds"

View file

@ -18,6 +18,8 @@ import {
HomeIconSolid,
MagnifyingGlassIcon2,
MagnifyingGlassIcon2Solid,
SatelliteDishIcon,
SatelliteDishIconSolid,
BellIcon,
BellIconSolid,
} from 'lib/icons'
@ -33,7 +35,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
const pal = usePalette('default')
const safeAreaInsets = useSafeAreaInsets()
const {track} = useAnalytics()
const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} =
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
useNavigationTabState()
const {footerMinimalShellTransform} = useMinimalShellMode()
@ -59,6 +61,10 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
() => onPressTab('Search'),
[onPressTab],
)
const onPressFeeds = React.useCallback(
() => onPressTab('Feeds'),
[onPressTab],
)
const onPressNotifications = React.useCallback(
() => onPressTab('Notifications'),
[onPressTab],
@ -120,6 +126,28 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
accessibilityLabel="Search"
accessibilityHint=""
/>
<Btn
testID="bottomBarFeedsBtn"
icon={
isAtFeeds ? (
<SatelliteDishIconSolid
size={25}
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
strokeWidth={1.8}
/>
) : (
<SatelliteDishIcon
size={25}
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
strokeWidth={1.8}
/>
)
}
onPress={onPressFeeds}
accessibilityRole="tab"
accessibilityLabel="Feeds"
accessibilityHint=""
/>
<Btn
testID="bottomBarNotificationsBtn"
icon={

View file

@ -207,7 +207,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
label="Notifications"
/>
<NavItem
href="/settings/saved-feeds"
href="/feeds"
icon={
<SatelliteDishIcon
strokeWidth={1.75}