about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/view/screens/SavedFeeds.tsx203
1 files changed, 114 insertions, 89 deletions
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index 66bbd9b8a..2334abb5d 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -1,22 +1,23 @@
 import React from 'react'
 import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native'
+import Animated, {LinearTransition} from 'react-native-reanimated'
 import {AppBskyActorDefs} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useFocusEffect} from '@react-navigation/native'
+import {useNavigation} from '@react-navigation/native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
 
 import {useHaptics} from '#/lib/haptics'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import {CommonNavigatorParams} from '#/lib/routes/types'
+import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
 import {colors, s} from '#/lib/styles'
 import {logger} from '#/logger'
 import {
   useOverwriteSavedFeedsMutation,
   usePreferencesQuery,
-  useUpdateSavedFeedsMutation,
 } from '#/state/queries/preferences'
 import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
 import {useSetMinimalShellMode} from '#/state/shell'
@@ -29,43 +30,40 @@ import {CenteredView, ScrollView} from '#/view/com/util/Views'
 import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
 import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
 import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
-
-const HITSLOP_TOP = {
-  top: 20,
-  left: 20,
-  bottom: 5,
-  right: 20,
-}
-const HITSLOP_BOTTOM = {
-  top: 5,
-  left: 20,
-  bottom: 20,
-  right: 20,
-}
+import {Loader} from '#/components/Loader'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
 export function SavedFeeds({}: Props) {
+  const {data: preferences} = usePreferencesQuery()
+  if (!preferences) {
+    return <View />
+  }
+  return <SavedFeedsInner preferences={preferences} />
+}
+
+function SavedFeedsInner({
+  preferences,
+}: {
+  preferences: UsePreferencesQueryResponse
+}) {
   const pal = usePalette('default')
   const {_} = useLingui()
-  const {isMobile, isTabletOrDesktop} = useWebMediaQueries()
+  const {isMobile, isTabletOrDesktop, isDesktop} = useWebMediaQueries()
   const setMinimalShellMode = useSetMinimalShellMode()
-  const {data: preferences} = usePreferencesQuery()
-  const {
-    mutateAsync: overwriteSavedFeeds,
-    variables: optimisticSavedFeedsResponse,
-    reset: resetSaveFeedsMutationState,
-    error: savedFeedsError,
-  } = useOverwriteSavedFeedsMutation()
+  const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} =
+    useOverwriteSavedFeedsMutation()
+  const navigation = useNavigation<NavigationProp>()
 
   /*
    * Use optimistic data if exists and no error, otherwise fallback to remote
    * data
    */
-  const currentFeeds =
-    optimisticSavedFeedsResponse && !savedFeedsError
-      ? optimisticSavedFeedsResponse
-      : preferences?.savedFeeds || []
+  const [currentFeeds, setCurrentFeeds] = React.useState(
+    () => preferences.savedFeeds || [],
+  )
+  const hasUnsavedChanges = currentFeeds !== preferences.savedFeeds
   const pinnedFeeds = currentFeeds.filter(f => f.pinned)
   const unpinnedFeeds = currentFeeds.filter(f => !f.pinned)
   const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0
@@ -78,6 +76,35 @@ export function SavedFeeds({}: Props) {
     }, [setMinimalShellMode]),
   )
 
+  const onSaveChanges = React.useCallback(async () => {
+    try {
+      await overwriteSavedFeeds(currentFeeds)
+      Toast.show(_(msg`Feeds updated!`))
+      navigation.navigate('Feeds')
+    } catch (e) {
+      Toast.show(_(msg`There was an issue contacting the server`), 'xmark')
+      logger.error('Failed to toggle pinned feed', {message: e})
+    }
+  }, [_, overwriteSavedFeeds, currentFeeds, navigation])
+
+  const renderHeaderBtn = React.useCallback(() => {
+    return (
+      <Button
+        size="small"
+        variant={hasUnsavedChanges ? 'solid' : 'solid'}
+        color={hasUnsavedChanges ? 'primary' : 'secondary'}
+        onPress={onSaveChanges}
+        label={_(msg`Save changes`)}
+        disabled={isOverwritePending || !hasUnsavedChanges}
+        style={[isDesktop && a.mt_sm]}>
+        <ButtonText style={[isDesktop && a.text_md]}>
+          {isDesktop ? <Trans>Save changes</Trans> : <Trans>Save</Trans>}
+        </ButtonText>
+        {isOverwritePending && <ButtonIcon icon={Loader} />}
+      </Button>
+    )
+  }, [_, isDesktop, onSaveChanges, hasUnsavedChanges, isOverwritePending])
+
   return (
     <CenteredView
       style={[
@@ -85,7 +112,12 @@ export function SavedFeeds({}: Props) {
         pal.border,
         isTabletOrDesktop && styles.desktopContainer,
       ]}>
-      <ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder />
+      <ViewHeader
+        title={_(msg`Edit My Feeds`)}
+        showOnDesktop
+        showBorder
+        renderButton={renderHeaderBtn}
+      />
       <ScrollView style={s.flex1} contentContainerStyle={[styles.noBorder]}>
         {noSavedFeedsOfAnyType && (
           <View
@@ -119,9 +151,8 @@ export function SavedFeeds({}: Props) {
                 key={f.id}
                 feed={f}
                 isPinned
-                overwriteSavedFeeds={overwriteSavedFeeds}
-                resetSaveFeedsMutationState={resetSaveFeedsMutationState}
                 currentFeeds={currentFeeds}
+                setCurrentFeeds={setCurrentFeeds}
                 preferences={preferences}
               />
             ))
@@ -161,9 +192,8 @@ export function SavedFeeds({}: Props) {
                 key={f.id}
                 feed={f}
                 isPinned={false}
-                overwriteSavedFeeds={overwriteSavedFeeds}
-                resetSaveFeedsMutationState={resetSaveFeedsMutationState}
                 currentFeeds={currentFeeds}
+                setCurrentFeeds={setCurrentFeeds}
                 preferences={preferences}
               />
             ))
@@ -197,44 +227,27 @@ function ListItem({
   feed,
   isPinned,
   currentFeeds,
-  overwriteSavedFeeds,
-  resetSaveFeedsMutationState,
+  setCurrentFeeds,
 }: {
   feed: AppBskyActorDefs.SavedFeed
   isPinned: boolean
   currentFeeds: AppBskyActorDefs.SavedFeed[]
-  overwriteSavedFeeds: ReturnType<
-    typeof useOverwriteSavedFeedsMutation
-  >['mutateAsync']
-  resetSaveFeedsMutationState: ReturnType<
-    typeof useOverwriteSavedFeedsMutation
-  >['reset']
+  setCurrentFeeds: React.Dispatch<AppBskyActorDefs.SavedFeed[]>
   preferences: UsePreferencesQueryResponse
 }) {
-  const pal = usePalette('default')
   const {_} = useLingui()
+  const pal = usePalette('default')
   const playHaptic = useHaptics()
-  const {isPending: isUpdatePending, mutateAsync: updateSavedFeeds} =
-    useUpdateSavedFeedsMutation()
   const feedUri = feed.value
 
   const onTogglePinned = React.useCallback(async () => {
     playHaptic()
-
-    try {
-      resetSaveFeedsMutationState()
-
-      await updateSavedFeeds([
-        {
-          ...feed,
-          pinned: !feed.pinned,
-        },
-      ])
-    } catch (e) {
-      Toast.show(_(msg`There was an issue contacting the server`), 'xmark')
-      logger.error('Failed to toggle pinned feed', {message: e})
-    }
-  }, [_, playHaptic, feed, updateSavedFeeds, resetSaveFeedsMutationState])
+    setCurrentFeeds(
+      currentFeeds.map(f =>
+        f.id === feed.id ? {...feed, pinned: !feed.pinned} : f,
+      ),
+    )
+  }, [playHaptic, feed, currentFeeds, setCurrentFeeds])
 
   const onPressUp = React.useCallback(async () => {
     if (!isPinned) return
@@ -250,13 +263,8 @@ function ListItem({
       nextFeeds[index],
     ]
 
-    try {
-      await overwriteSavedFeeds(nextFeeds)
-    } catch (e) {
-      Toast.show(_(msg`There was an issue contacting the server`), 'xmark')
-      logger.error('Failed to set pinned feed order', {message: e})
-    }
-  }, [feed, isPinned, overwriteSavedFeeds, currentFeeds, _])
+    setCurrentFeeds(nextFeeds)
+  }, [feed, isPinned, setCurrentFeeds, currentFeeds])
 
   const onPressDown = React.useCallback(async () => {
     if (!isPinned) return
@@ -266,22 +274,25 @@ function ListItem({
     const index = ids.indexOf(feed.id)
     const nextIndex = index + 1
 
-    if (index === -1 || index >= nextFeeds.length - 1) return
+    if (index === -1 || index >= nextFeeds.filter(f => f.pinned).length - 1)
+      return
     ;[nextFeeds[index], nextFeeds[nextIndex]] = [
       nextFeeds[nextIndex],
       nextFeeds[index],
     ]
 
-    try {
-      await overwriteSavedFeeds(nextFeeds)
-    } catch (e) {
-      Toast.show(_(msg`There was an issue contacting the server`), 'xmark')
-      logger.error('Failed to set pinned feed order', {message: e})
-    }
-  }, [feed, isPinned, overwriteSavedFeeds, currentFeeds, _])
+    setCurrentFeeds(nextFeeds)
+  }, [feed, isPinned, setCurrentFeeds, currentFeeds])
+
+  const onPressRemove = React.useCallback(async () => {
+    playHaptic()
+    setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id))
+  }, [playHaptic, feed, currentFeeds, setCurrentFeeds])
 
   return (
-    <View style={[styles.itemContainer, pal.border]}>
+    <Animated.View
+      style={[styles.itemContainer, pal.border]}
+      layout={LinearTransition.duration(100)}>
       {feed.type === 'timeline' ? (
         <FollowingFeedCard />
       ) : (
@@ -290,25 +301,22 @@ function ListItem({
           feedUri={feedUri}
           style={[isPinned && {paddingRight: 8}]}
           showMinimalPlaceholder
-          showSaveBtn={!isPinned}
           hideTopBorder={true}
         />
       )}
       {isPinned ? (
         <>
           <Pressable
-            disabled={isUpdatePending}
             accessibilityRole="button"
             onPress={onPressUp}
-            hitSlop={HITSLOP_TOP}
+            hitSlop={5}
             style={state => ({
               backgroundColor: pal.viewLight.backgroundColor,
               paddingHorizontal: 12,
               paddingVertical: 10,
               borderRadius: 4,
               marginRight: 8,
-              opacity:
-                state.hovered || state.pressed || isUpdatePending ? 0.5 : 1,
+              opacity: state.hovered || state.pressed ? 0.5 : 1,
             })}>
             <FontAwesomeIcon
               icon="arrow-up"
@@ -317,18 +325,16 @@ function ListItem({
             />
           </Pressable>
           <Pressable
-            disabled={isUpdatePending}
             accessibilityRole="button"
             onPress={onPressDown}
-            hitSlop={HITSLOP_BOTTOM}
+            hitSlop={5}
             style={state => ({
               backgroundColor: pal.viewLight.backgroundColor,
               paddingHorizontal: 12,
               paddingVertical: 10,
               borderRadius: 4,
               marginRight: 8,
-              opacity:
-                state.hovered || state.pressed || isUpdatePending ? 0.5 : 1,
+              opacity: state.hovered || state.pressed ? 0.5 : 1,
             })}>
             <FontAwesomeIcon
               icon="arrow-down"
@@ -337,20 +343,39 @@ function ListItem({
             />
           </Pressable>
         </>
-      ) : null}
+      ) : (
+        <Pressable
+          testID={`feed-${feedUri}-toggleSave`}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Remove from my feeds`)}
+          accessibilityHint=""
+          onPress={onPressRemove}
+          hitSlop={5}
+          style={state => ({
+            marginRight: 8,
+            paddingHorizontal: 12,
+            paddingVertical: 10,
+            borderRadius: 4,
+            opacity: state.hovered || state.focused ? 0.5 : 1,
+          })}>
+          <FontAwesomeIcon
+            icon={['far', 'trash-can']}
+            size={19}
+            color={pal.colors.icon}
+          />
+        </Pressable>
+      )}
       <View style={{paddingRight: 16}}>
         <Pressable
-          disabled={isUpdatePending}
           accessibilityRole="button"
-          hitSlop={10}
+          hitSlop={5}
           onPress={onTogglePinned}
           style={state => ({
             backgroundColor: pal.viewLight.backgroundColor,
             paddingHorizontal: 12,
             paddingVertical: 10,
             borderRadius: 4,
-            opacity:
-              state.hovered || state.focused || isUpdatePending ? 0.5 : 1,
+            opacity: state.hovered || state.focused ? 0.5 : 1,
           })}>
           <FontAwesomeIcon
             icon="thumb-tack"
@@ -359,7 +384,7 @@ function ListItem({
           />
         </Pressable>
       </View>
-    </View>
+    </Animated.View>
   )
 }