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 {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>
)
}