Fix performance of feed reordering and add layout animations (#5714)

* Rework SavedFeeds editor to make changes transactionally

* Fix hit slops

* Add layout animations

* Fix: dont let down go too far down

* Speed up layout transitions
This commit is contained in:
Paul Frazee
2024-10-11 16:12:14 -07:00
committed by GitHub
parent f7852d02ba
commit 157011efe3
+114 -89
View File
@@ -1,22 +1,23 @@
import React from 'react' import React from 'react'
import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native' import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native'
import Animated, {LinearTransition} from 'react-native-reanimated'
import {AppBskyActorDefs} from '@atproto/api' import {AppBskyActorDefs} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
import {useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack' import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 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 {colors, s} from '#/lib/styles'
import {logger} from '#/logger' import {logger} from '#/logger'
import { import {
useOverwriteSavedFeedsMutation, useOverwriteSavedFeedsMutation,
usePreferencesQuery, usePreferencesQuery,
useUpdateSavedFeedsMutation,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
@@ -29,43 +30,40 @@ import {CenteredView, ScrollView} from '#/view/com/util/Views'
import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
import {atoms as a, useTheme} from '#/alf' 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' import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
import {Loader} from '#/components/Loader'
const HITSLOP_TOP = {
top: 20,
left: 20,
bottom: 5,
right: 20,
}
const HITSLOP_BOTTOM = {
top: 5,
left: 20,
bottom: 20,
right: 20,
}
type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
export function SavedFeeds({}: Props) { 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 pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const {isMobile, isTabletOrDesktop} = useWebMediaQueries() const {isMobile, isTabletOrDesktop, isDesktop} = useWebMediaQueries()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const {data: preferences} = usePreferencesQuery() const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} =
const { useOverwriteSavedFeedsMutation()
mutateAsync: overwriteSavedFeeds, const navigation = useNavigation<NavigationProp>()
variables: optimisticSavedFeedsResponse,
reset: resetSaveFeedsMutationState,
error: savedFeedsError,
} = useOverwriteSavedFeedsMutation()
/* /*
* Use optimistic data if exists and no error, otherwise fallback to remote * Use optimistic data if exists and no error, otherwise fallback to remote
* data * data
*/ */
const currentFeeds = const [currentFeeds, setCurrentFeeds] = React.useState(
optimisticSavedFeedsResponse && !savedFeedsError () => preferences.savedFeeds || [],
? optimisticSavedFeedsResponse )
: preferences?.savedFeeds || [] const hasUnsavedChanges = currentFeeds !== preferences.savedFeeds
const pinnedFeeds = currentFeeds.filter(f => f.pinned) const pinnedFeeds = currentFeeds.filter(f => f.pinned)
const unpinnedFeeds = currentFeeds.filter(f => !f.pinned) const unpinnedFeeds = currentFeeds.filter(f => !f.pinned)
const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0 const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0
@@ -78,6 +76,35 @@ export function SavedFeeds({}: Props) {
}, [setMinimalShellMode]), }, [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 ( return (
<CenteredView <CenteredView
style={[ style={[
@@ -85,7 +112,12 @@ export function SavedFeeds({}: Props) {
pal.border, pal.border,
isTabletOrDesktop && styles.desktopContainer, 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]}> <ScrollView style={s.flex1} contentContainerStyle={[styles.noBorder]}>
{noSavedFeedsOfAnyType && ( {noSavedFeedsOfAnyType && (
<View <View
@@ -119,9 +151,8 @@ export function SavedFeeds({}: Props) {
key={f.id} key={f.id}
feed={f} feed={f}
isPinned isPinned
overwriteSavedFeeds={overwriteSavedFeeds}
resetSaveFeedsMutationState={resetSaveFeedsMutationState}
currentFeeds={currentFeeds} currentFeeds={currentFeeds}
setCurrentFeeds={setCurrentFeeds}
preferences={preferences} preferences={preferences}
/> />
)) ))
@@ -161,9 +192,8 @@ export function SavedFeeds({}: Props) {
key={f.id} key={f.id}
feed={f} feed={f}
isPinned={false} isPinned={false}
overwriteSavedFeeds={overwriteSavedFeeds}
resetSaveFeedsMutationState={resetSaveFeedsMutationState}
currentFeeds={currentFeeds} currentFeeds={currentFeeds}
setCurrentFeeds={setCurrentFeeds}
preferences={preferences} preferences={preferences}
/> />
)) ))
@@ -197,44 +227,27 @@ function ListItem({
feed, feed,
isPinned, isPinned,
currentFeeds, currentFeeds,
overwriteSavedFeeds, setCurrentFeeds,
resetSaveFeedsMutationState,
}: { }: {
feed: AppBskyActorDefs.SavedFeed feed: AppBskyActorDefs.SavedFeed
isPinned: boolean isPinned: boolean
currentFeeds: AppBskyActorDefs.SavedFeed[] currentFeeds: AppBskyActorDefs.SavedFeed[]
overwriteSavedFeeds: ReturnType< setCurrentFeeds: React.Dispatch<AppBskyActorDefs.SavedFeed[]>
typeof useOverwriteSavedFeedsMutation
>['mutateAsync']
resetSaveFeedsMutationState: ReturnType<
typeof useOverwriteSavedFeedsMutation
>['reset']
preferences: UsePreferencesQueryResponse preferences: UsePreferencesQueryResponse
}) { }) {
const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const pal = usePalette('default')
const playHaptic = useHaptics() const playHaptic = useHaptics()
const {isPending: isUpdatePending, mutateAsync: updateSavedFeeds} =
useUpdateSavedFeedsMutation()
const feedUri = feed.value const feedUri = feed.value
const onTogglePinned = React.useCallback(async () => { const onTogglePinned = React.useCallback(async () => {
playHaptic() playHaptic()
setCurrentFeeds(
try { currentFeeds.map(f =>
resetSaveFeedsMutationState() f.id === feed.id ? {...feed, pinned: !feed.pinned} : f,
),
await updateSavedFeeds([ )
{ }, [playHaptic, feed, currentFeeds, setCurrentFeeds])
...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])
const onPressUp = React.useCallback(async () => { const onPressUp = React.useCallback(async () => {
if (!isPinned) return if (!isPinned) return
@@ -250,13 +263,8 @@ function ListItem({
nextFeeds[index], nextFeeds[index],
] ]
try { setCurrentFeeds(nextFeeds)
await overwriteSavedFeeds(nextFeeds) }, [feed, isPinned, setCurrentFeeds, currentFeeds])
} 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, _])
const onPressDown = React.useCallback(async () => { const onPressDown = React.useCallback(async () => {
if (!isPinned) return if (!isPinned) return
@@ -266,22 +274,25 @@ function ListItem({
const index = ids.indexOf(feed.id) const index = ids.indexOf(feed.id)
const nextIndex = index + 1 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[index], nextFeeds[nextIndex]] = [
nextFeeds[nextIndex], nextFeeds[nextIndex],
nextFeeds[index], nextFeeds[index],
] ]
try { setCurrentFeeds(nextFeeds)
await overwriteSavedFeeds(nextFeeds) }, [feed, isPinned, setCurrentFeeds, currentFeeds])
} catch (e) {
Toast.show(_(msg`There was an issue contacting the server`), 'xmark') const onPressRemove = React.useCallback(async () => {
logger.error('Failed to set pinned feed order', {message: e}) playHaptic()
} setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id))
}, [feed, isPinned, overwriteSavedFeeds, currentFeeds, _]) }, [playHaptic, feed, currentFeeds, setCurrentFeeds])
return ( return (
<View style={[styles.itemContainer, pal.border]}> <Animated.View
style={[styles.itemContainer, pal.border]}
layout={LinearTransition.duration(100)}>
{feed.type === 'timeline' ? ( {feed.type === 'timeline' ? (
<FollowingFeedCard /> <FollowingFeedCard />
) : ( ) : (
@@ -290,25 +301,22 @@ function ListItem({
feedUri={feedUri} feedUri={feedUri}
style={[isPinned && {paddingRight: 8}]} style={[isPinned && {paddingRight: 8}]}
showMinimalPlaceholder showMinimalPlaceholder
showSaveBtn={!isPinned}
hideTopBorder={true} hideTopBorder={true}
/> />
)} )}
{isPinned ? ( {isPinned ? (
<> <>
<Pressable <Pressable
disabled={isUpdatePending}
accessibilityRole="button" accessibilityRole="button"
onPress={onPressUp} onPress={onPressUp}
hitSlop={HITSLOP_TOP} hitSlop={5}
style={state => ({ style={state => ({
backgroundColor: pal.viewLight.backgroundColor, backgroundColor: pal.viewLight.backgroundColor,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
borderRadius: 4, borderRadius: 4,
marginRight: 8, marginRight: 8,
opacity: opacity: state.hovered || state.pressed ? 0.5 : 1,
state.hovered || state.pressed || isUpdatePending ? 0.5 : 1,
})}> })}>
<FontAwesomeIcon <FontAwesomeIcon
icon="arrow-up" icon="arrow-up"
@@ -317,18 +325,16 @@ function ListItem({
/> />
</Pressable> </Pressable>
<Pressable <Pressable
disabled={isUpdatePending}
accessibilityRole="button" accessibilityRole="button"
onPress={onPressDown} onPress={onPressDown}
hitSlop={HITSLOP_BOTTOM} hitSlop={5}
style={state => ({ style={state => ({
backgroundColor: pal.viewLight.backgroundColor, backgroundColor: pal.viewLight.backgroundColor,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
borderRadius: 4, borderRadius: 4,
marginRight: 8, marginRight: 8,
opacity: opacity: state.hovered || state.pressed ? 0.5 : 1,
state.hovered || state.pressed || isUpdatePending ? 0.5 : 1,
})}> })}>
<FontAwesomeIcon <FontAwesomeIcon
icon="arrow-down" icon="arrow-down"
@@ -337,20 +343,39 @@ function ListItem({
/> />
</Pressable> </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}}> <View style={{paddingRight: 16}}>
<Pressable <Pressable
disabled={isUpdatePending}
accessibilityRole="button" accessibilityRole="button"
hitSlop={10} hitSlop={5}
onPress={onTogglePinned} onPress={onTogglePinned}
style={state => ({ style={state => ({
backgroundColor: pal.viewLight.backgroundColor, backgroundColor: pal.viewLight.backgroundColor,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
borderRadius: 4, borderRadius: 4,
opacity: opacity: state.hovered || state.focused ? 0.5 : 1,
state.hovered || state.focused || isUpdatePending ? 0.5 : 1,
})}> })}>
<FontAwesomeIcon <FontAwesomeIcon
icon="thumb-tack" icon="thumb-tack"
@@ -359,7 +384,7 @@ function ListItem({
/> />
</Pressable> </Pressable>
</View> </View>
</View> </Animated.View>
) )
} }