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
zio/stable
Eric Bailey 2023-11-29 18:17:27 -06:00 committed by GitHub
parent 3ca4bd805a
commit a59d235e8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 115 additions and 57 deletions

View File

@ -23,6 +23,7 @@ import {
useRemoveFeedMutation, useRemoveFeedMutation,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed' import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
export function FeedSourceCard({ export function FeedSourceCard({
feedUri, feedUri,
@ -30,17 +31,25 @@ export function FeedSourceCard({
showSaveBtn = false, showSaveBtn = false,
showDescription = false, showDescription = false,
showLikes = false, showLikes = false,
LoadingComponent,
}: { }: {
feedUri: string feedUri: string
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
showSaveBtn?: boolean showSaveBtn?: boolean
showDescription?: boolean showDescription?: boolean
showLikes?: boolean showLikes?: boolean
LoadingComponent?: JSX.Element
}) { }) {
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
const {data: feed} = useFeedSourceInfoQuery({uri: feedUri}) const {data: feed} = useFeedSourceInfoQuery({uri: feedUri})
if (!feed || !preferences) return null if (!feed || !preferences) {
return LoadingComponent ? (
LoadingComponent
) : (
<FeedLoadingPlaceholder style={{flex: 1}} />
)
}
return ( return (
<FeedSourceCardLoaded <FeedSourceCardLoaded

View File

@ -171,14 +171,22 @@ export function ProfileCardFeedLoadingPlaceholder() {
export function FeedLoadingPlaceholder({ export function FeedLoadingPlaceholder({
style, style,
showLowerPlaceholder = true,
showTopBorder = true,
}: { }: {
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
showTopBorder?: boolean
showLowerPlaceholder?: boolean
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
<View <View
style={[ style={[
{paddingHorizontal: 12, paddingVertical: 18, borderTopWidth: 1}, {
paddingHorizontal: 12,
paddingVertical: 18,
borderTopWidth: showTopBorder ? 1 : 0,
},
pal.border, pal.border,
style, style,
]}> ]}>
@ -193,6 +201,7 @@ export function FeedLoadingPlaceholder({
<LoadingPlaceholder width={120} height={8} /> <LoadingPlaceholder width={120} height={8} />
</View> </View>
</View> </View>
{showLowerPlaceholder && (
<View style={{paddingHorizontal: 5}}> <View style={{paddingHorizontal: 5}}>
<LoadingPlaceholder <LoadingPlaceholder
width={260} width={260}
@ -201,6 +210,7 @@ export function FeedLoadingPlaceholder({
/> />
<LoadingPlaceholder width={120} height={8} /> <LoadingPlaceholder width={120} height={8} />
</View> </View>
)}
</View> </View>
) )
} }

View File

@ -1,14 +1,7 @@
import React from 'react' import React from 'react'
import { import {StyleSheet, View, ActivityIndicator, Pressable} from 'react-native'
StyleSheet,
View,
ActivityIndicator,
Pressable,
TouchableOpacity,
} from 'react-native'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack' import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query'
import {track} from '#/lib/analytics/analytics' import {track} from '#/lib/analytics/analytics'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
@ -32,9 +25,8 @@ import {
usePinFeedMutation, usePinFeedMutation,
useUnpinFeedMutation, useUnpinFeedMutation,
useSetSaveFeedsMutation, useSetSaveFeedsMutation,
preferencesQueryKey,
UsePreferencesQueryResponse,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
const HITSLOP_TOP = { const HITSLOP_TOP = {
top: 20, top: 20,
@ -57,6 +49,24 @@ export function SavedFeeds({}: Props) {
const {screen} = useAnalytics() const {screen} = useAnalytics()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const {data: preferences} = usePreferencesQuery() 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( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
@ -80,7 +90,7 @@ export function SavedFeeds({}: Props) {
</Text> </Text>
</View> </View>
{preferences?.feeds ? ( {preferences?.feeds ? (
!preferences.feeds.pinned.length ? ( !currentFeeds.pinned.length ? (
<View <View
style={[ style={[
pal.border, pal.border,
@ -93,8 +103,15 @@ export function SavedFeeds({}: Props) {
</Text> </Text>
</View> </View>
) : ( ) : (
preferences?.feeds?.pinned?.map(uri => ( currentFeeds.pinned.map(uri => (
<ListItem key={uri} feedUri={uri} isPinned /> <ListItem
key={uri}
feedUri={uri}
isPinned
setSavedFeeds={setSavedFeeds}
resetSaveFeedsMutationState={resetSaveFeedsMutationState}
currentFeeds={currentFeeds}
/>
)) ))
) )
) : ( ) : (
@ -106,7 +123,7 @@ export function SavedFeeds({}: Props) {
</Text> </Text>
</View> </View>
{preferences?.feeds ? ( {preferences?.feeds ? (
!preferences.feeds.unpinned.length ? ( !unpinned.length ? (
<View <View
style={[ style={[
pal.border, pal.border,
@ -119,8 +136,15 @@ export function SavedFeeds({}: Props) {
</Text> </Text>
</View> </View>
) : ( ) : (
preferences.feeds.unpinned.map(uri => ( unpinned.map(uri => (
<ListItem key={uri} feedUri={uri} isPinned={false} /> <ListItem
key={uri}
feedUri={uri}
isPinned={false}
setSavedFeeds={setSavedFeeds}
resetSaveFeedsMutationState={resetSaveFeedsMutationState}
currentFeeds={currentFeeds}
/>
)) ))
) )
) : ( ) : (
@ -151,22 +175,30 @@ export function SavedFeeds({}: Props) {
function ListItem({ function ListItem({
feedUri, feedUri,
isPinned, isPinned,
currentFeeds,
setSavedFeeds,
resetSaveFeedsMutationState,
}: { }: {
feedUri: string // uri feedUri: string // uri
isPinned: boolean isPinned: boolean
currentFeeds: {saved: string[]; pinned: string[]}
setSavedFeeds: ReturnType<typeof useSetSaveFeedsMutation>['mutateAsync']
resetSaveFeedsMutationState: ReturnType<
typeof useSetSaveFeedsMutation
>['reset']
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const queryClient = useQueryClient()
const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation()
const {isPending: isUnpinPending, mutateAsync: unpinFeed} = const {isPending: isUnpinPending, mutateAsync: unpinFeed} =
useUnpinFeedMutation() useUnpinFeedMutation()
const {isPending: isMovePending, mutateAsync: setSavedFeeds} = const isPending = isPinPending || isUnpinPending
useSetSaveFeedsMutation()
const onTogglePinned = React.useCallback(async () => { const onTogglePinned = React.useCallback(async () => {
Haptics.default() Haptics.default()
try { try {
resetSaveFeedsMutationState()
if (isPinned) { if (isPinned) {
await unpinFeed({uri: feedUri}) await unpinFeed({uri: feedUri})
} else { } else {
@ -176,24 +208,20 @@ function ListItem({
Toast.show('There was an issue contacting the server') Toast.show('There was an issue contacting the server')
logger.error('Failed to toggle pinned feed', {error: e}) logger.error('Failed to toggle pinned feed', {error: e})
} }
}, [feedUri, isPinned, pinFeed, unpinFeed]) }, [feedUri, isPinned, pinFeed, unpinFeed, resetSaveFeedsMutationState])
const onPressUp = React.useCallback(async () => { const onPressUp = React.useCallback(async () => {
if (!isPinned) return if (!isPinned) return
const feeds =
queryClient.getQueryData<UsePreferencesQueryResponse>(
preferencesQueryKey,
)?.feeds
// create new array, do not mutate // create new array, do not mutate
const pinned = feeds?.pinned ? [...feeds.pinned] : [] const pinned = [...currentFeeds.pinned]
const index = pinned.indexOf(feedUri) const index = pinned.indexOf(feedUri)
if (index === -1 || index === 0) return if (index === -1 || index === 0) return
;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]] ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]]
try { try {
await setSavedFeeds({saved: feeds?.saved ?? [], pinned}) await setSavedFeeds({saved: currentFeeds.saved, pinned})
track('CustomFeed:Reorder', { track('CustomFeed:Reorder', {
uri: feedUri, uri: feedUri,
index: pinned.indexOf(feedUri), index: pinned.indexOf(feedUri),
@ -202,24 +230,19 @@ function ListItem({
Toast.show('There was an issue contacting the server') Toast.show('There was an issue contacting the server')
logger.error('Failed to set pinned feed order', {error: e}) logger.error('Failed to set pinned feed order', {error: e})
} }
}, [feedUri, isPinned, queryClient, setSavedFeeds]) }, [feedUri, isPinned, setSavedFeeds, currentFeeds])
const onPressDown = React.useCallback(async () => { const onPressDown = React.useCallback(async () => {
if (!isPinned) return if (!isPinned) return
const feeds = const pinned = [...currentFeeds.pinned]
queryClient.getQueryData<UsePreferencesQueryResponse>(
preferencesQueryKey,
)?.feeds
// create new array, do not mutate
const pinned = feeds?.pinned ? [...feeds.pinned] : []
const index = pinned.indexOf(feedUri) const index = pinned.indexOf(feedUri)
if (index === -1 || index >= pinned.length - 1) return if (index === -1 || index >= pinned.length - 1) return
;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]] ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]]
try { try {
await setSavedFeeds({saved: feeds?.saved ?? [], pinned}) await setSavedFeeds({saved: currentFeeds.saved, pinned})
track('CustomFeed:Reorder', { track('CustomFeed:Reorder', {
uri: feedUri, uri: feedUri,
index: pinned.indexOf(feedUri), index: pinned.indexOf(feedUri),
@ -228,7 +251,7 @@ function ListItem({
Toast.show('There was an issue contacting the server') Toast.show('There was an issue contacting the server')
logger.error('Failed to set pinned feed order', {error: e}) logger.error('Failed to set pinned feed order', {error: e})
} }
}, [feedUri, isPinned, queryClient, setSavedFeeds]) }, [feedUri, isPinned, setSavedFeeds, currentFeeds])
return ( return (
<Pressable <Pressable
@ -236,24 +259,30 @@ function ListItem({
style={[styles.itemContainer, pal.border]}> style={[styles.itemContainer, pal.border]}>
{isPinned ? ( {isPinned ? (
<View style={styles.webArrowButtonsContainer}> <View style={styles.webArrowButtonsContainer}>
<TouchableOpacity <Pressable
disabled={isMovePending} disabled={isPending}
accessibilityRole="button" accessibilityRole="button"
onPress={onPressUp} onPress={onPressUp}
hitSlop={HITSLOP_TOP}> hitSlop={HITSLOP_TOP}
style={state => ({
opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
})}>
<FontAwesomeIcon <FontAwesomeIcon
icon="arrow-up" icon="arrow-up"
size={12} size={12}
style={[pal.text, styles.webArrowUpButton]} style={[pal.text, styles.webArrowUpButton]}
/> />
</TouchableOpacity> </Pressable>
<TouchableOpacity <Pressable
disabled={isMovePending} disabled={isPending}
accessibilityRole="button" accessibilityRole="button"
onPress={onPressDown} 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]} /> <FontAwesomeIcon icon="arrow-down" size={12} style={[pal.text]} />
</TouchableOpacity> </Pressable>
</View> </View>
) : null} ) : null}
<FeedSourceCard <FeedSourceCard
@ -261,18 +290,28 @@ function ListItem({
feedUri={feedUri} feedUri={feedUri}
style={styles.noBorder} style={styles.noBorder}
showSaveBtn showSaveBtn
LoadingComponent={
<FeedLoadingPlaceholder
style={{flex: 1}}
showLowerPlaceholder={false}
showTopBorder={false}
/> />
<TouchableOpacity }
disabled={isPinPending || isUnpinPending} />
<Pressable
disabled={isPending}
accessibilityRole="button" accessibilityRole="button"
hitSlop={10} hitSlop={10}
onPress={onTogglePinned}> onPress={onTogglePinned}
style={state => ({
opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
})}>
<FontAwesomeIcon <FontAwesomeIcon
icon="thumb-tack" icon="thumb-tack"
size={20} size={20}
color={isPinned ? colors.blue3 : pal.colors.icon} color={isPinned ? colors.blue3 : pal.colors.icon}
/> />
</TouchableOpacity> </Pressable>
</Pressable> </Pressable>
) )
} }