From a59d235e8b319f6c33e0a0221c99c915128826a6 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 29 Nov 2023 18:17:27 -0600 Subject: [PATCH] Improve feed reordering with optimistic updates (#2032) * Optimisticaly update order of saved feeds * Better show disabled state for pin button * Improve loading/disabled states * Improve placeholder * Simplify loading components --- src/view/com/feeds/FeedSourceCard.tsx | 11 +- src/view/com/util/LoadingPlaceholder.tsx | 28 +++-- src/view/screens/SavedFeeds.tsx | 133 +++++++++++++++-------- 3 files changed, 115 insertions(+), 57 deletions(-) diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index d8b67767..9acbf361 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -23,6 +23,7 @@ import { useRemoveFeedMutation, } from '#/state/queries/preferences' import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' export function FeedSourceCard({ feedUri, @@ -30,17 +31,25 @@ export function FeedSourceCard({ showSaveBtn = false, showDescription = false, showLikes = false, + LoadingComponent, }: { feedUri: string style?: StyleProp showSaveBtn?: boolean showDescription?: boolean showLikes?: boolean + LoadingComponent?: JSX.Element }) { const {data: preferences} = usePreferencesQuery() const {data: feed} = useFeedSourceInfoQuery({uri: feedUri}) - if (!feed || !preferences) return null + if (!feed || !preferences) { + return LoadingComponent ? ( + LoadingComponent + ) : ( + + ) + } return ( + showTopBorder?: boolean + showLowerPlaceholder?: boolean }) { const pal = usePalette('default') return ( @@ -193,14 +201,16 @@ export function FeedLoadingPlaceholder({ - - - - + {showLowerPlaceholder && ( + + + + + )} ) } diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index ce668877..858a58a3 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -1,14 +1,7 @@ import React from 'react' -import { - StyleSheet, - View, - ActivityIndicator, - Pressable, - TouchableOpacity, -} from 'react-native' +import {StyleSheet, View, ActivityIndicator, Pressable} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' -import {useQueryClient} from '@tanstack/react-query' import {track} from '#/lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' @@ -32,9 +25,8 @@ import { usePinFeedMutation, useUnpinFeedMutation, useSetSaveFeedsMutation, - preferencesQueryKey, - UsePreferencesQueryResponse, } from '#/state/queries/preferences' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' const HITSLOP_TOP = { top: 20, @@ -57,6 +49,24 @@ export function SavedFeeds({}: Props) { 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) + }) useFocusEffect( React.useCallback(() => { @@ -80,7 +90,7 @@ export function SavedFeeds({}: Props) { {preferences?.feeds ? ( - !preferences.feeds.pinned.length ? ( + !currentFeeds.pinned.length ? ( ) : ( - preferences?.feeds?.pinned?.map(uri => ( - + currentFeeds.pinned.map(uri => ( + )) ) ) : ( @@ -106,7 +123,7 @@ export function SavedFeeds({}: Props) { {preferences?.feeds ? ( - !preferences.feeds.unpinned.length ? ( + !unpinned.length ? ( ) : ( - preferences.feeds.unpinned.map(uri => ( - + unpinned.map(uri => ( + )) ) ) : ( @@ -151,22 +175,30 @@ export function SavedFeeds({}: Props) { function ListItem({ feedUri, isPinned, + currentFeeds, + setSavedFeeds, + resetSaveFeedsMutationState, }: { feedUri: string // uri isPinned: boolean + currentFeeds: {saved: string[]; pinned: string[]} + setSavedFeeds: ReturnType['mutateAsync'] + resetSaveFeedsMutationState: ReturnType< + typeof useSetSaveFeedsMutation + >['reset'] }) { const pal = usePalette('default') - const queryClient = useQueryClient() const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() const {isPending: isUnpinPending, mutateAsync: unpinFeed} = useUnpinFeedMutation() - const {isPending: isMovePending, mutateAsync: setSavedFeeds} = - useSetSaveFeedsMutation() + const isPending = isPinPending || isUnpinPending const onTogglePinned = React.useCallback(async () => { Haptics.default() try { + resetSaveFeedsMutationState() + if (isPinned) { await unpinFeed({uri: feedUri}) } else { @@ -176,24 +208,20 @@ function ListItem({ Toast.show('There was an issue contacting the server') logger.error('Failed to toggle pinned feed', {error: e}) } - }, [feedUri, isPinned, pinFeed, unpinFeed]) + }, [feedUri, isPinned, pinFeed, unpinFeed, resetSaveFeedsMutationState]) const onPressUp = React.useCallback(async () => { if (!isPinned) return - const feeds = - queryClient.getQueryData( - preferencesQueryKey, - )?.feeds // create new array, do not mutate - const pinned = feeds?.pinned ? [...feeds.pinned] : [] + 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: feeds?.saved ?? [], pinned}) + await setSavedFeeds({saved: currentFeeds.saved, pinned}) track('CustomFeed:Reorder', { uri: feedUri, index: pinned.indexOf(feedUri), @@ -202,24 +230,19 @@ function ListItem({ Toast.show('There was an issue contacting the server') logger.error('Failed to set pinned feed order', {error: e}) } - }, [feedUri, isPinned, queryClient, setSavedFeeds]) + }, [feedUri, isPinned, setSavedFeeds, currentFeeds]) const onPressDown = React.useCallback(async () => { if (!isPinned) return - const feeds = - queryClient.getQueryData( - preferencesQueryKey, - )?.feeds - // create new array, do not mutate - const pinned = feeds?.pinned ? [...feeds.pinned] : [] + 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: feeds?.saved ?? [], pinned}) + await setSavedFeeds({saved: currentFeeds.saved, pinned}) track('CustomFeed:Reorder', { uri: feedUri, index: pinned.indexOf(feedUri), @@ -228,7 +251,7 @@ function ListItem({ Toast.show('There was an issue contacting the server') logger.error('Failed to set pinned feed order', {error: e}) } - }, [feedUri, isPinned, queryClient, setSavedFeeds]) + }, [feedUri, isPinned, setSavedFeeds, currentFeeds]) return ( {isPinned ? ( - + hitSlop={HITSLOP_TOP} + style={state => ({ + opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + })}> - - + + hitSlop={HITSLOP_BOTTOM} + style={state => ({ + opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + })}> - + ) : null} + } /> - + onPress={onTogglePinned} + style={state => ({ + opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + })}> - + ) }