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,
} 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<ViewStyle>
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
) : (
<FeedLoadingPlaceholder style={{flex: 1}} />
)
}
return (
<FeedSourceCardLoaded

View File

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

View File

@ -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) {
</Text>
</View>
{preferences?.feeds ? (
!preferences.feeds.pinned.length ? (
!currentFeeds.pinned.length ? (
<View
style={[
pal.border,
@ -93,8 +103,15 @@ export function SavedFeeds({}: Props) {
</Text>
</View>
) : (
preferences?.feeds?.pinned?.map(uri => (
<ListItem key={uri} feedUri={uri} isPinned />
currentFeeds.pinned.map(uri => (
<ListItem
key={uri}
feedUri={uri}
isPinned
setSavedFeeds={setSavedFeeds}
resetSaveFeedsMutationState={resetSaveFeedsMutationState}
currentFeeds={currentFeeds}
/>
))
)
) : (
@ -106,7 +123,7 @@ export function SavedFeeds({}: Props) {
</Text>
</View>
{preferences?.feeds ? (
!preferences.feeds.unpinned.length ? (
!unpinned.length ? (
<View
style={[
pal.border,
@ -119,8 +136,15 @@ export function SavedFeeds({}: Props) {
</Text>
</View>
) : (
preferences.feeds.unpinned.map(uri => (
<ListItem key={uri} feedUri={uri} isPinned={false} />
unpinned.map(uri => (
<ListItem
key={uri}
feedUri={uri}
isPinned={false}
setSavedFeeds={setSavedFeeds}
resetSaveFeedsMutationState={resetSaveFeedsMutationState}
currentFeeds={currentFeeds}
/>
))
)
) : (
@ -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<typeof useSetSaveFeedsMutation>['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<UsePreferencesQueryResponse>(
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<UsePreferencesQueryResponse>(
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 (
<Pressable
@ -236,24 +259,30 @@ function ListItem({
style={[styles.itemContainer, pal.border]}>
{isPinned ? (
<View style={styles.webArrowButtonsContainer}>
<TouchableOpacity
disabled={isMovePending}
<Pressable
disabled={isPending}
accessibilityRole="button"
onPress={onPressUp}
hitSlop={HITSLOP_TOP}>
hitSlop={HITSLOP_TOP}
style={state => ({
opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
})}>
<FontAwesomeIcon
icon="arrow-up"
size={12}
style={[pal.text, styles.webArrowUpButton]}
/>
</TouchableOpacity>
<TouchableOpacity
disabled={isMovePending}
</Pressable>
<Pressable
disabled={isPending}
accessibilityRole="button"
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]} />
</TouchableOpacity>
</Pressable>
</View>
) : null}
<FeedSourceCard
@ -261,18 +290,28 @@ function ListItem({
feedUri={feedUri}
style={styles.noBorder}
showSaveBtn
LoadingComponent={
<FeedLoadingPlaceholder
style={{flex: 1}}
showLowerPlaceholder={false}
showTopBorder={false}
/>
<TouchableOpacity
disabled={isPinPending || isUnpinPending}
}
/>
<Pressable
disabled={isPending}
accessibilityRole="button"
hitSlop={10}
onPress={onTogglePinned}>
onPress={onTogglePinned}
style={state => ({
opacity: state.hovered || state.focused || isPending ? 0.5 : 1,
})}>
<FontAwesomeIcon
icon="thumb-tack"
size={20}
color={isPinned ? colors.blue3 : pal.colors.icon}
/>
</TouchableOpacity>
</Pressable>
</Pressable>
)
}