about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx8
-rw-r--r--src/view/com/pager/TabBar.tsx2
-rw-r--r--src/view/com/posts/Feed.tsx2
-rw-r--r--src/view/com/posts/MultiFeed.tsx230
-rw-r--r--src/view/screens/Feeds.tsx125
-rw-r--r--src/view/screens/SavedFeeds.tsx6
-rw-r--r--src/view/shell/Drawer.tsx30
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx30
-rw-r--r--src/view/shell/desktop/LeftNav.tsx2
9 files changed, 412 insertions, 23 deletions
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index 574265eb7..0d71b2b98 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -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>
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index bbba74c87..b0a02ea22 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -131,7 +131,7 @@ const styles = isDesktopWeb
         backgroundColor: 'transparent',
       },
       contentContainer: {
-        columnGap: 16,
+        columnGap: 20,
         marginLeft: 18,
         paddingRight: 28,
         backgroundColor: 'transparent',
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index b90213472..8206ca509 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -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}
diff --git a/src/view/com/posts/MultiFeed.tsx b/src/view/com/posts/MultiFeed.tsx
new file mode 100644
index 000000000..4911c9e2c
--- /dev/null
+++ b/src/view/com/posts/MultiFeed.tsx
@@ -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,
+  },
+})
diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx
new file mode 100644
index 000000000..5d5ed6c16
--- /dev/null
+++ b/src/view/screens/Feeds.tsx
@@ -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,
+  },
+})
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index dac554710..103b18c70 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -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}
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 5617cd5b8..57f4ee696 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -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"
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index 394aef7af..e8cba9047 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -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],
@@ -121,6 +127,28 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
         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={
           isAtNotifications ? (
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index e62b47ca9..3b14d7e99 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -207,7 +207,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
         label="Notifications"
       />
       <NavItem
-        href="/settings/saved-feeds"
+        href="/feeds"
         icon={
           <SatelliteDishIcon
             strokeWidth={1.75}