Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Paul Frazee and committed by
GitHub
157011ef f7852d02

+114 -89
+114 -89
src/view/screens/SavedFeeds.tsx
··· 1 1 import React from 'react' 2 2 import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native' 3 + import Animated, {LinearTransition} from 'react-native-reanimated' 3 4 import {AppBskyActorDefs} from '@atproto/api' 4 5 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 6 import {msg, Trans} from '@lingui/macro' 6 7 import {useLingui} from '@lingui/react' 7 8 import {useFocusEffect} from '@react-navigation/native' 9 + import {useNavigation} from '@react-navigation/native' 8 10 import {NativeStackScreenProps} from '@react-navigation/native-stack' 9 11 10 12 import {useHaptics} from '#/lib/haptics' 11 13 import {usePalette} from '#/lib/hooks/usePalette' 12 14 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 13 - import {CommonNavigatorParams} from '#/lib/routes/types' 15 + import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' 14 16 import {colors, s} from '#/lib/styles' 15 17 import {logger} from '#/logger' 16 18 import { 17 19 useOverwriteSavedFeedsMutation, 18 20 usePreferencesQuery, 19 - useUpdateSavedFeedsMutation, 20 21 } from '#/state/queries/preferences' 21 22 import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' 22 23 import {useSetMinimalShellMode} from '#/state/shell' ··· 29 30 import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' 30 31 import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' 31 32 import {atoms as a, useTheme} from '#/alf' 33 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 32 34 import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' 35 + import {Loader} from '#/components/Loader' 33 36 34 - const HITSLOP_TOP = { 35 - top: 20, 36 - left: 20, 37 - bottom: 5, 38 - right: 20, 39 - } 40 - const HITSLOP_BOTTOM = { 41 - top: 5, 42 - left: 20, 43 - bottom: 20, 44 - right: 20, 37 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> 38 + export function SavedFeeds({}: Props) { 39 + const {data: preferences} = usePreferencesQuery() 40 + if (!preferences) { 41 + return <View /> 42 + } 43 + return <SavedFeedsInner preferences={preferences} /> 45 44 } 46 45 47 - type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> 48 - export function SavedFeeds({}: Props) { 46 + function SavedFeedsInner({ 47 + preferences, 48 + }: { 49 + preferences: UsePreferencesQueryResponse 50 + }) { 49 51 const pal = usePalette('default') 50 52 const {_} = useLingui() 51 - const {isMobile, isTabletOrDesktop} = useWebMediaQueries() 53 + const {isMobile, isTabletOrDesktop, isDesktop} = useWebMediaQueries() 52 54 const setMinimalShellMode = useSetMinimalShellMode() 53 - const {data: preferences} = usePreferencesQuery() 54 - const { 55 - mutateAsync: overwriteSavedFeeds, 56 - variables: optimisticSavedFeedsResponse, 57 - reset: resetSaveFeedsMutationState, 58 - error: savedFeedsError, 59 - } = useOverwriteSavedFeedsMutation() 55 + const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} = 56 + useOverwriteSavedFeedsMutation() 57 + const navigation = useNavigation<NavigationProp>() 60 58 61 59 /* 62 60 * Use optimistic data if exists and no error, otherwise fallback to remote 63 61 * data 64 62 */ 65 - const currentFeeds = 66 - optimisticSavedFeedsResponse && !savedFeedsError 67 - ? optimisticSavedFeedsResponse 68 - : preferences?.savedFeeds || [] 63 + const [currentFeeds, setCurrentFeeds] = React.useState( 64 + () => preferences.savedFeeds || [], 65 + ) 66 + const hasUnsavedChanges = currentFeeds !== preferences.savedFeeds 69 67 const pinnedFeeds = currentFeeds.filter(f => f.pinned) 70 68 const unpinnedFeeds = currentFeeds.filter(f => !f.pinned) 71 69 const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0 ··· 78 76 }, [setMinimalShellMode]), 79 77 ) 80 78 79 + const onSaveChanges = React.useCallback(async () => { 80 + try { 81 + await overwriteSavedFeeds(currentFeeds) 82 + Toast.show(_(msg`Feeds updated!`)) 83 + navigation.navigate('Feeds') 84 + } catch (e) { 85 + Toast.show(_(msg`There was an issue contacting the server`), 'xmark') 86 + logger.error('Failed to toggle pinned feed', {message: e}) 87 + } 88 + }, [_, overwriteSavedFeeds, currentFeeds, navigation]) 89 + 90 + const renderHeaderBtn = React.useCallback(() => { 91 + return ( 92 + <Button 93 + size="small" 94 + variant={hasUnsavedChanges ? 'solid' : 'solid'} 95 + color={hasUnsavedChanges ? 'primary' : 'secondary'} 96 + onPress={onSaveChanges} 97 + label={_(msg`Save changes`)} 98 + disabled={isOverwritePending || !hasUnsavedChanges} 99 + style={[isDesktop && a.mt_sm]}> 100 + <ButtonText style={[isDesktop && a.text_md]}> 101 + {isDesktop ? <Trans>Save changes</Trans> : <Trans>Save</Trans>} 102 + </ButtonText> 103 + {isOverwritePending && <ButtonIcon icon={Loader} />} 104 + </Button> 105 + ) 106 + }, [_, isDesktop, onSaveChanges, hasUnsavedChanges, isOverwritePending]) 107 + 81 108 return ( 82 109 <CenteredView 83 110 style={[ ··· 85 112 pal.border, 86 113 isTabletOrDesktop && styles.desktopContainer, 87 114 ]}> 88 - <ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder /> 115 + <ViewHeader 116 + title={_(msg`Edit My Feeds`)} 117 + showOnDesktop 118 + showBorder 119 + renderButton={renderHeaderBtn} 120 + /> 89 121 <ScrollView style={s.flex1} contentContainerStyle={[styles.noBorder]}> 90 122 {noSavedFeedsOfAnyType && ( 91 123 <View ··· 119 151 key={f.id} 120 152 feed={f} 121 153 isPinned 122 - overwriteSavedFeeds={overwriteSavedFeeds} 123 - resetSaveFeedsMutationState={resetSaveFeedsMutationState} 124 154 currentFeeds={currentFeeds} 155 + setCurrentFeeds={setCurrentFeeds} 125 156 preferences={preferences} 126 157 /> 127 158 )) ··· 161 192 key={f.id} 162 193 feed={f} 163 194 isPinned={false} 164 - overwriteSavedFeeds={overwriteSavedFeeds} 165 - resetSaveFeedsMutationState={resetSaveFeedsMutationState} 166 195 currentFeeds={currentFeeds} 196 + setCurrentFeeds={setCurrentFeeds} 167 197 preferences={preferences} 168 198 /> 169 199 )) ··· 197 227 feed, 198 228 isPinned, 199 229 currentFeeds, 200 - overwriteSavedFeeds, 201 - resetSaveFeedsMutationState, 230 + setCurrentFeeds, 202 231 }: { 203 232 feed: AppBskyActorDefs.SavedFeed 204 233 isPinned: boolean 205 234 currentFeeds: AppBskyActorDefs.SavedFeed[] 206 - overwriteSavedFeeds: ReturnType< 207 - typeof useOverwriteSavedFeedsMutation 208 - >['mutateAsync'] 209 - resetSaveFeedsMutationState: ReturnType< 210 - typeof useOverwriteSavedFeedsMutation 211 - >['reset'] 235 + setCurrentFeeds: React.Dispatch<AppBskyActorDefs.SavedFeed[]> 212 236 preferences: UsePreferencesQueryResponse 213 237 }) { 214 - const pal = usePalette('default') 215 238 const {_} = useLingui() 239 + const pal = usePalette('default') 216 240 const playHaptic = useHaptics() 217 - const {isPending: isUpdatePending, mutateAsync: updateSavedFeeds} = 218 - useUpdateSavedFeedsMutation() 219 241 const feedUri = feed.value 220 242 221 243 const onTogglePinned = React.useCallback(async () => { 222 244 playHaptic() 223 - 224 - try { 225 - resetSaveFeedsMutationState() 226 - 227 - await updateSavedFeeds([ 228 - { 229 - ...feed, 230 - pinned: !feed.pinned, 231 - }, 232 - ]) 233 - } catch (e) { 234 - Toast.show(_(msg`There was an issue contacting the server`), 'xmark') 235 - logger.error('Failed to toggle pinned feed', {message: e}) 236 - } 237 - }, [_, playHaptic, feed, updateSavedFeeds, resetSaveFeedsMutationState]) 245 + setCurrentFeeds( 246 + currentFeeds.map(f => 247 + f.id === feed.id ? {...feed, pinned: !feed.pinned} : f, 248 + ), 249 + ) 250 + }, [playHaptic, feed, currentFeeds, setCurrentFeeds]) 238 251 239 252 const onPressUp = React.useCallback(async () => { 240 253 if (!isPinned) return ··· 250 263 nextFeeds[index], 251 264 ] 252 265 253 - try { 254 - await overwriteSavedFeeds(nextFeeds) 255 - } catch (e) { 256 - Toast.show(_(msg`There was an issue contacting the server`), 'xmark') 257 - logger.error('Failed to set pinned feed order', {message: e}) 258 - } 259 - }, [feed, isPinned, overwriteSavedFeeds, currentFeeds, _]) 266 + setCurrentFeeds(nextFeeds) 267 + }, [feed, isPinned, setCurrentFeeds, currentFeeds]) 260 268 261 269 const onPressDown = React.useCallback(async () => { 262 270 if (!isPinned) return ··· 266 274 const index = ids.indexOf(feed.id) 267 275 const nextIndex = index + 1 268 276 269 - if (index === -1 || index >= nextFeeds.length - 1) return 277 + if (index === -1 || index >= nextFeeds.filter(f => f.pinned).length - 1) 278 + return 270 279 ;[nextFeeds[index], nextFeeds[nextIndex]] = [ 271 280 nextFeeds[nextIndex], 272 281 nextFeeds[index], 273 282 ] 274 283 275 - try { 276 - await overwriteSavedFeeds(nextFeeds) 277 - } catch (e) { 278 - Toast.show(_(msg`There was an issue contacting the server`), 'xmark') 279 - logger.error('Failed to set pinned feed order', {message: e}) 280 - } 281 - }, [feed, isPinned, overwriteSavedFeeds, currentFeeds, _]) 284 + setCurrentFeeds(nextFeeds) 285 + }, [feed, isPinned, setCurrentFeeds, currentFeeds]) 286 + 287 + const onPressRemove = React.useCallback(async () => { 288 + playHaptic() 289 + setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id)) 290 + }, [playHaptic, feed, currentFeeds, setCurrentFeeds]) 282 291 283 292 return ( 284 - <View style={[styles.itemContainer, pal.border]}> 293 + <Animated.View 294 + style={[styles.itemContainer, pal.border]} 295 + layout={LinearTransition.duration(100)}> 285 296 {feed.type === 'timeline' ? ( 286 297 <FollowingFeedCard /> 287 298 ) : ( ··· 290 301 feedUri={feedUri} 291 302 style={[isPinned && {paddingRight: 8}]} 292 303 showMinimalPlaceholder 293 - showSaveBtn={!isPinned} 294 304 hideTopBorder={true} 295 305 /> 296 306 )} 297 307 {isPinned ? ( 298 308 <> 299 309 <Pressable 300 - disabled={isUpdatePending} 301 310 accessibilityRole="button" 302 311 onPress={onPressUp} 303 - hitSlop={HITSLOP_TOP} 312 + hitSlop={5} 304 313 style={state => ({ 305 314 backgroundColor: pal.viewLight.backgroundColor, 306 315 paddingHorizontal: 12, 307 316 paddingVertical: 10, 308 317 borderRadius: 4, 309 318 marginRight: 8, 310 - opacity: 311 - state.hovered || state.pressed || isUpdatePending ? 0.5 : 1, 319 + opacity: state.hovered || state.pressed ? 0.5 : 1, 312 320 })}> 313 321 <FontAwesomeIcon 314 322 icon="arrow-up" ··· 317 325 /> 318 326 </Pressable> 319 327 <Pressable 320 - disabled={isUpdatePending} 321 328 accessibilityRole="button" 322 329 onPress={onPressDown} 323 - hitSlop={HITSLOP_BOTTOM} 330 + hitSlop={5} 324 331 style={state => ({ 325 332 backgroundColor: pal.viewLight.backgroundColor, 326 333 paddingHorizontal: 12, 327 334 paddingVertical: 10, 328 335 borderRadius: 4, 329 336 marginRight: 8, 330 - opacity: 331 - state.hovered || state.pressed || isUpdatePending ? 0.5 : 1, 337 + opacity: state.hovered || state.pressed ? 0.5 : 1, 332 338 })}> 333 339 <FontAwesomeIcon 334 340 icon="arrow-down" ··· 337 343 /> 338 344 </Pressable> 339 345 </> 340 - ) : null} 346 + ) : ( 347 + <Pressable 348 + testID={`feed-${feedUri}-toggleSave`} 349 + accessibilityRole="button" 350 + accessibilityLabel={_(msg`Remove from my feeds`)} 351 + accessibilityHint="" 352 + onPress={onPressRemove} 353 + hitSlop={5} 354 + style={state => ({ 355 + marginRight: 8, 356 + paddingHorizontal: 12, 357 + paddingVertical: 10, 358 + borderRadius: 4, 359 + opacity: state.hovered || state.focused ? 0.5 : 1, 360 + })}> 361 + <FontAwesomeIcon 362 + icon={['far', 'trash-can']} 363 + size={19} 364 + color={pal.colors.icon} 365 + /> 366 + </Pressable> 367 + )} 341 368 <View style={{paddingRight: 16}}> 342 369 <Pressable 343 - disabled={isUpdatePending} 344 370 accessibilityRole="button" 345 - hitSlop={10} 371 + hitSlop={5} 346 372 onPress={onTogglePinned} 347 373 style={state => ({ 348 374 backgroundColor: pal.viewLight.backgroundColor, 349 375 paddingHorizontal: 12, 350 376 paddingVertical: 10, 351 377 borderRadius: 4, 352 - opacity: 353 - state.hovered || state.focused || isUpdatePending ? 0.5 : 1, 378 + opacity: state.hovered || state.focused ? 0.5 : 1, 354 379 })}> 355 380 <FontAwesomeIcon 356 381 icon="thumb-tack" ··· 359 384 /> 360 385 </Pressable> 361 386 </View> 362 - </View> 387 + </Animated.View> 363 388 ) 364 389 } 365 390