about summary refs log tree commit diff
path: root/src/view/screens/SavedFeeds.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/screens/SavedFeeds.tsx')
-rw-r--r--src/view/screens/SavedFeeds.tsx380
1 files changed, 232 insertions, 148 deletions
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index 487f56643..858a58a3c 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -1,33 +1,32 @@
-import React, {useCallback, useMemo} from 'react'
-import {
-  StyleSheet,
-  View,
-  ActivityIndicator,
-  Pressable,
-  TouchableOpacity,
-} from 'react-native'
+import React from 'react'
+import {StyleSheet, View, ActivityIndicator, Pressable} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {track} from '#/lib/analytics/analytics'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {CommonNavigatorParams} from 'lib/routes/types'
-import {observer} from 'mobx-react-lite'
-import {useStores} from 'state/index'
-import {SavedFeedsModel} from 'state/models/ui/saved-feeds'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewHeader} from 'view/com/util/ViewHeader'
 import {ScrollView, CenteredView} from 'view/com/util/Views'
 import {Text} from 'view/com/util/text/Text'
 import {s, colors} from 'lib/styles'
 import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
-import {FeedSourceModel} from 'state/models/content/feed-source'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import * as Toast from 'view/com/util/Toast'
 import {Haptics} from 'lib/haptics'
 import {TextLink} from 'view/com/util/Link'
 import {logger} from '#/logger'
 import {useSetMinimalShellMode} from '#/state/shell'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {
+  usePreferencesQuery,
+  usePinFeedMutation,
+  useUnpinFeedMutation,
+  useSetSaveFeedsMutation,
+} from '#/state/queries/preferences'
+import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
 
 const HITSLOP_TOP = {
   top: 20,
@@ -43,99 +42,118 @@ const HITSLOP_BOTTOM = {
 }
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
-export const SavedFeeds = withAuthRequired(
-  observer(function SavedFeedsImpl({}: Props) {
-    const pal = usePalette('default')
-    const store = useStores()
-    const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
-    const {screen} = useAnalytics()
-    const setMinimalShellMode = useSetMinimalShellMode()
+export function SavedFeeds({}: Props) {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
+  const {screen} = useAnalytics()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {data: preferences} = usePreferencesQuery()
+  const {
+    mutateAsync: setSavedFeeds,
+    variables: optimisticSavedFeedsResponse,
+    reset: resetSaveFeedsMutationState,
+    error: setSavedFeedsError,
+  } = useSetSaveFeedsMutation()
+
+  /*
+   * Use optimistic data if exists and no error, otherwise fallback to remote
+   * data
+   */
+  const currentFeeds =
+    optimisticSavedFeedsResponse && !setSavedFeedsError
+      ? optimisticSavedFeedsResponse
+      : preferences?.feeds || {saved: [], pinned: []}
+  const unpinned = currentFeeds.saved.filter(f => {
+    return !currentFeeds.pinned?.includes(f)
+  })
 
-    const savedFeeds = useMemo(() => {
-      const model = new SavedFeedsModel(store)
-      model.refresh()
-      return model
-    }, [store])
-    useFocusEffect(
-      useCallback(() => {
-        screen('SavedFeeds')
-        setMinimalShellMode(false)
-        savedFeeds.refresh()
-      }, [screen, setMinimalShellMode, savedFeeds]),
-    )
+  useFocusEffect(
+    React.useCallback(() => {
+      screen('SavedFeeds')
+      setMinimalShellMode(false)
+    }, [screen, setMinimalShellMode]),
+  )
 
-    return (
-      <CenteredView
-        style={[
-          s.hContentRegion,
-          pal.border,
-          isTabletOrDesktop && styles.desktopContainer,
-        ]}>
-        <ViewHeader title="Edit My Feeds" showOnDesktop showBorder />
-        <ScrollView style={s.flex1}>
-          <View style={[pal.text, pal.border, styles.title]}>
-            <Text type="title" style={pal.text}>
-              Pinned Feeds
-            </Text>
-          </View>
-          {savedFeeds.hasLoaded ? (
-            !savedFeeds.pinned.length ? (
-              <View
-                style={[
-                  pal.border,
-                  isMobile && s.flex1,
-                  pal.viewLight,
-                  styles.empty,
-                ]}>
-                <Text type="lg" style={[pal.text]}>
-                  You don't have any pinned feeds.
-                </Text>
-              </View>
-            ) : (
-              savedFeeds.pinned.map(feed => (
-                <ListItem
-                  key={feed._reactKey}
-                  savedFeeds={savedFeeds}
-                  item={feed}
-                />
-              ))
-            )
+  return (
+    <CenteredView
+      style={[
+        s.hContentRegion,
+        pal.border,
+        isTabletOrDesktop && styles.desktopContainer,
+      ]}>
+      <ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder />
+      <ScrollView style={s.flex1}>
+        <View style={[pal.text, pal.border, styles.title]}>
+          <Text type="title" style={pal.text}>
+            <Trans>Pinned Feeds</Trans>
+          </Text>
+        </View>
+        {preferences?.feeds ? (
+          !currentFeeds.pinned.length ? (
+            <View
+              style={[
+                pal.border,
+                isMobile && s.flex1,
+                pal.viewLight,
+                styles.empty,
+              ]}>
+              <Text type="lg" style={[pal.text]}>
+                <Trans>You don't have any pinned feeds.</Trans>
+              </Text>
+            </View>
           ) : (
-            <ActivityIndicator style={{marginTop: 20}} />
-          )}
-          <View style={[pal.text, pal.border, styles.title]}>
-            <Text type="title" style={pal.text}>
-              Saved Feeds
-            </Text>
-          </View>
-          {savedFeeds.hasLoaded ? (
-            !savedFeeds.unpinned.length ? (
-              <View
-                style={[
-                  pal.border,
-                  isMobile && s.flex1,
-                  pal.viewLight,
-                  styles.empty,
-                ]}>
-                <Text type="lg" style={[pal.text]}>
-                  You don't have any saved feeds.
-                </Text>
-              </View>
-            ) : (
-              savedFeeds.unpinned.map(feed => (
-                <ListItem
-                  key={feed._reactKey}
-                  savedFeeds={savedFeeds}
-                  item={feed}
-                />
-              ))
-            )
+            currentFeeds.pinned.map(uri => (
+              <ListItem
+                key={uri}
+                feedUri={uri}
+                isPinned
+                setSavedFeeds={setSavedFeeds}
+                resetSaveFeedsMutationState={resetSaveFeedsMutationState}
+                currentFeeds={currentFeeds}
+              />
+            ))
+          )
+        ) : (
+          <ActivityIndicator style={{marginTop: 20}} />
+        )}
+        <View style={[pal.text, pal.border, styles.title]}>
+          <Text type="title" style={pal.text}>
+            <Trans>Saved Feeds</Trans>
+          </Text>
+        </View>
+        {preferences?.feeds ? (
+          !unpinned.length ? (
+            <View
+              style={[
+                pal.border,
+                isMobile && s.flex1,
+                pal.viewLight,
+                styles.empty,
+              ]}>
+              <Text type="lg" style={[pal.text]}>
+                <Trans>You don't have any saved feeds.</Trans>
+              </Text>
+            </View>
           ) : (
-            <ActivityIndicator style={{marginTop: 20}} />
-          )}
+            unpinned.map(uri => (
+              <ListItem
+                key={uri}
+                feedUri={uri}
+                isPinned={false}
+                setSavedFeeds={setSavedFeeds}
+                resetSaveFeedsMutationState={resetSaveFeedsMutationState}
+                currentFeeds={currentFeeds}
+              />
+            ))
+          )
+        ) : (
+          <ActivityIndicator style={{marginTop: 20}} />
+        )}
 
-          <View style={styles.footerText}>
-            <Text type="sm" style={pal.textLight}>
+        <View style={styles.footerText}>
+          <Text type="sm" style={pal.textLight}>
+            <Trans>
               Feeds are custom algorithms that users build with a little coding
               expertise.{' '}
               <TextLink
@@ -145,48 +163,95 @@ export const SavedFeeds = withAuthRequired(
                 text="See this guide"
               />{' '}
               for more information.
-            </Text>
-          </View>
-          <View style={{height: 100}} />
-        </ScrollView>
-      </CenteredView>
-    )
-  }),
-)
+            </Trans>
+          </Text>
+        </View>
+        <View style={{height: 100}} />
+      </ScrollView>
+    </CenteredView>
+  )
+}
 
-const ListItem = observer(function ListItemImpl({
-  savedFeeds,
-  item,
+function ListItem({
+  feedUri,
+  isPinned,
+  currentFeeds,
+  setSavedFeeds,
+  resetSaveFeedsMutationState,
 }: {
-  savedFeeds: SavedFeedsModel
-  item: FeedSourceModel
+  feedUri: string // uri
+  isPinned: boolean
+  currentFeeds: {saved: string[]; pinned: string[]}
+  setSavedFeeds: ReturnType<typeof useSetSaveFeedsMutation>['mutateAsync']
+  resetSaveFeedsMutationState: ReturnType<
+    typeof useSetSaveFeedsMutation
+  >['reset']
 }) {
   const pal = usePalette('default')
-  const isPinned = item.isPinned
+  const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
+  const {isPending: isUnpinPending, mutateAsync: unpinFeed} =
+    useUnpinFeedMutation()
+  const isPending = isPinPending || isUnpinPending
 
-  const onTogglePinned = useCallback(() => {
+  const onTogglePinned = React.useCallback(async () => {
     Haptics.default()
-    item.togglePin().catch(e => {
+
+    try {
+      resetSaveFeedsMutationState()
+
+      if (isPinned) {
+        await unpinFeed({uri: feedUri})
+      } else {
+        await pinFeed({uri: feedUri})
+      }
+    } catch (e) {
       Toast.show('There was an issue contacting the server')
       logger.error('Failed to toggle pinned feed', {error: e})
-    })
-  }, [item])
-  const onPressUp = useCallback(
-    () =>
-      savedFeeds.movePinnedFeed(item, 'up').catch(e => {
-        Toast.show('There was an issue contacting the server')
-        logger.error('Failed to set pinned feed order', {error: e})
-      }),
-    [savedFeeds, item],
-  )
-  const onPressDown = useCallback(
-    () =>
-      savedFeeds.movePinnedFeed(item, 'down').catch(e => {
-        Toast.show('There was an issue contacting the server')
-        logger.error('Failed to set pinned feed order', {error: e})
-      }),
-    [savedFeeds, item],
-  )
+    }
+  }, [feedUri, isPinned, pinFeed, unpinFeed, resetSaveFeedsMutationState])
+
+  const onPressUp = React.useCallback(async () => {
+    if (!isPinned) return
+
+    // create new array, do not mutate
+    const pinned = [...currentFeeds.pinned]
+    const index = pinned.indexOf(feedUri)
+
+    if (index === -1 || index === 0) return
+    ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]]
+
+    try {
+      await setSavedFeeds({saved: currentFeeds.saved, pinned})
+      track('CustomFeed:Reorder', {
+        uri: feedUri,
+        index: pinned.indexOf(feedUri),
+      })
+    } catch (e) {
+      Toast.show('There was an issue contacting the server')
+      logger.error('Failed to set pinned feed order', {error: e})
+    }
+  }, [feedUri, isPinned, setSavedFeeds, currentFeeds])
+
+  const onPressDown = React.useCallback(async () => {
+    if (!isPinned) return
+
+    const pinned = [...currentFeeds.pinned]
+    const index = pinned.indexOf(feedUri)
+
+    if (index === -1 || index >= pinned.length - 1) return
+    ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]]
+
+    try {
+      await setSavedFeeds({saved: currentFeeds.saved, pinned})
+      track('CustomFeed:Reorder', {
+        uri: feedUri,
+        index: pinned.indexOf(feedUri),
+      })
+    } catch (e) {
+      Toast.show('There was an issue contacting the server')
+      logger.error('Failed to set pinned feed order', {error: e})
+    }
+  }, [feedUri, isPinned, setSavedFeeds, currentFeeds])
 
   return (
     <Pressable
@@ -194,43 +259,62 @@ const ListItem = observer(function ListItemImpl({
       style={[styles.itemContainer, pal.border]}>
       {isPinned ? (
         <View style={styles.webArrowButtonsContainer}>
-          <TouchableOpacity
+          <Pressable
+            disabled={isPending}
             accessibilityRole="button"
             onPress={onPressUp}
-            hitSlop={HITSLOP_TOP}>
+            hitSlop={HITSLOP_TOP}
+            style={state => ({
+              opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
+            })}>
             <FontAwesomeIcon
               icon="arrow-up"
               size={12}
               style={[pal.text, styles.webArrowUpButton]}
             />
-          </TouchableOpacity>
-          <TouchableOpacity
+          </Pressable>
+          <Pressable
+            disabled={isPending}
             accessibilityRole="button"
             onPress={onPressDown}
-            hitSlop={HITSLOP_BOTTOM}>
+            hitSlop={HITSLOP_BOTTOM}
+            style={state => ({
+              opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
+            })}>
             <FontAwesomeIcon icon="arrow-down" size={12} style={[pal.text]} />
-          </TouchableOpacity>
+          </Pressable>
         </View>
       ) : null}
       <FeedSourceCard
-        key={item.uri}
-        item={item}
-        showSaveBtn
+        key={feedUri}
+        feedUri={feedUri}
         style={styles.noBorder}
+        showSaveBtn
+        LoadingComponent={
+          <FeedLoadingPlaceholder
+            style={{flex: 1}}
+            showLowerPlaceholder={false}
+            showTopBorder={false}
+          />
+        }
       />
-      <TouchableOpacity
+      <Pressable
+        disabled={isPending}
         accessibilityRole="button"
         hitSlop={10}
-        onPress={onTogglePinned}>
+        onPress={onTogglePinned}
+        style={state => ({
+          opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
+        })}>
         <FontAwesomeIcon
           icon="thumb-tack"
           size={20}
           color={isPinned ? colors.blue3 : pal.colors.icon}
         />
-      </TouchableOpacity>
+      </Pressable>
     </Pressable>
   )
-})
+}
 
 const styles = StyleSheet.create({
   desktopContainer: {