deer social fork for personal usage. but you might see a use idk. github mirror

Floating "Load more" makeover (#8420)

authored by samuel.fm and committed by GitHub 4303bca8 342f820e

Changed files
+109 -112
assets
src
components
view
com
feeds
posts
util
load-latest
+1
assets/icons/arrowTop_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M11 20V6.164l-4.293 4.293a1 1 0 1 1-1.414-1.414l5.293-5.293.151-.138a2 2 0 0 1 2.677.138l5.293 5.293.068.076a1 1 0 0 1-1.406 1.406l-.076-.068L13 6.164V20a1 1 0 0 1-2 0Z"/></svg>
+1 -1
src/components/SubtleWebHover.web.tsx
··· 1 1 import {StyleSheet, View} from 'react-native' 2 2 3 3 import {isTouchDevice} from '#/lib/browser' 4 - import {useTheme, ViewStyleProp} from '#/alf' 4 + import {useTheme, type ViewStyleProp} from '#/alf' 5 5 6 6 export function SubtleWebHover({ 7 7 style,
+4
src/components/icons/Arrow.tsx
··· 4 4 path: 'M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z', 5 5 }) 6 6 7 + export const ArrowTop_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 + path: 'M11 20V6.164l-4.293 4.293a1 1 0 1 1-1.414-1.414l5.293-5.293.151-.138a2 2 0 0 1 2.677.138l5.293 5.293.068.076a1 1 0 0 1-1.406 1.406l-.076-.068L13 6.164V20a1 1 0 0 1-2 0Z', 9 + }) 10 + 7 11 export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 12 path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z', 9 13 })
+13 -13
src/view/com/feeds/FeedPage.tsx
··· 1 - import React from 'react' 1 + import {useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 2 import {View} from 'react-native' 3 3 import {type AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' 4 4 import {msg} from '@lingui/macro' ··· 58 58 const navigation = useNavigation<NavigationProp<AllNavigatorParams>>() 59 59 const queryClient = useQueryClient() 60 60 const {openComposer} = useOpenComposer() 61 - const [isScrolledDown, setIsScrolledDown] = React.useState(false) 61 + const [isScrolledDown, setIsScrolledDown] = useState(false) 62 62 const setMinimalShellMode = useSetMinimalShellMode() 63 63 const headerOffset = useHeaderOffset() 64 64 const feedFeedback = useFeedFeedback(feed, hasSession) 65 - const scrollElRef = React.useRef<ListMethods>(null) 66 - const [hasNew, setHasNew] = React.useState(false) 65 + const scrollElRef = useRef<ListMethods>(null) 66 + const [hasNew, setHasNew] = useState(false) 67 67 const setHomeBadge = useSetHomeBadge() 68 - const isVideoFeed = React.useMemo(() => { 68 + const isVideoFeed = useMemo(() => { 69 69 const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri) 70 70 const feedIsVideoMode = 71 71 feedInfo.contentMode === AppBskyFeedDefs.CONTENTMODEVIDEO ··· 73 73 return isNative && _isVideoFeed 74 74 }, [feedInfo]) 75 75 76 - React.useEffect(() => { 76 + useEffect(() => { 77 77 if (isPageFocused) { 78 78 setHomeBadge(hasNew) 79 79 } 80 80 }, [isPageFocused, hasNew, setHomeBadge]) 81 81 82 - const scrollToTop = React.useCallback(() => { 82 + const scrollToTop = useCallback(() => { 83 83 scrollElRef.current?.scrollToOffset({ 84 84 animated: isNative, 85 85 offset: -headerOffset, ··· 87 87 setMinimalShellMode(false) 88 88 }, [headerOffset, setMinimalShellMode]) 89 89 90 - const onSoftReset = React.useCallback(() => { 90 + const onSoftReset = useCallback(() => { 91 91 const isScreenFocused = 92 92 getTabState(getRootNavigation(navigation).getState(), 'Home') === 93 93 TabState.InsideAtRoot ··· 101 101 reason: 'soft-reset', 102 102 }) 103 103 } 104 - }, [navigation, isPageFocused, scrollToTop, queryClient, feed, setHasNew]) 104 + }, [navigation, isPageFocused, scrollToTop, queryClient, feed]) 105 105 106 106 // fires when page within screen is activated/deactivated 107 - React.useEffect(() => { 107 + useEffect(() => { 108 108 if (!isPageFocused) { 109 109 return 110 110 } 111 111 return listenSoftReset(onSoftReset) 112 112 }, [onSoftReset, isPageFocused]) 113 113 114 - const onPressCompose = React.useCallback(() => { 114 + const onPressCompose = useCallback(() => { 115 115 openComposer({}) 116 116 }, [openComposer]) 117 117 118 - const onPressLoadLatest = React.useCallback(() => { 118 + const onPressLoadLatest = useCallback(() => { 119 119 scrollToTop() 120 120 truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) 121 121 setHasNew(false) ··· 124 124 feedUrl: feed, 125 125 reason: 'load-latest', 126 126 }) 127 - }, [scrollToTop, feed, queryClient, setHasNew]) 127 + }, [scrollToTop, feed, queryClient]) 128 128 129 129 const shouldPrefetch = isNative && isPageAdjacent 130 130 return (
+35 -48
src/view/com/posts/PostFeed.tsx
··· 1 - import React, {memo, useCallback, useRef} from 'react' 1 + import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 2 import { 3 3 ActivityIndicator, 4 4 AppState, ··· 22 22 import {isStatusStillActive, validateStatus} from '#/lib/actor-status' 23 23 import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' 24 24 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 25 + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 25 26 import {logEvent} from '#/lib/statsig/statsig' 26 27 import {logger} from '#/logger' 27 28 import {isIOS, isNative, isWeb} from '#/platform/detection' ··· 208 209 const {currentAccount, hasSession} = useSession() 209 210 const initialNumToRender = useInitialNumToRender() 210 211 const feedFeedback = useFeedFeedbackContext() 211 - const [isPTRing, setIsPTRing] = React.useState(false) 212 - const checkForNewRef = React.useRef<(() => void) | null>(null) 213 - const lastFetchRef = React.useRef<number>(Date.now()) 212 + const [isPTRing, setIsPTRing] = useState(false) 213 + const lastFetchRef = useRef<number>(Date.now()) 214 214 const [feedType, feedUriOrActorDid, feedTab] = feed.split('|') 215 215 const {gtMobile} = useBreakpoints() 216 216 const {rightNavVisible} = useLayoutBreakpoints() 217 217 const areVideoFeedsEnabled = isNative 218 218 219 - const [hasPressedShowLessUris, setHasPressedShowLessUris] = React.useState( 219 + const [hasPressedShowLessUris, setHasPressedShowLessUris] = useState( 220 220 () => new Set<string>(), 221 221 ) 222 222 const onPressShowLess = useCallback( ··· 231 231 ) 232 232 233 233 const feedCacheKey = feedParams?.feedCacheKey 234 - const opts = React.useMemo( 234 + const opts = useMemo( 235 235 () => ({enabled, ignoreFilterFor}), 236 236 [enabled, ignoreFilterFor], 237 237 ) ··· 250 250 if (lastFetchedAt) { 251 251 lastFetchRef.current = lastFetchedAt 252 252 } 253 - const isEmpty = React.useMemo( 253 + const isEmpty = useMemo( 254 254 () => !isFetching && !data?.pages?.some(page => page.slices.length), 255 255 [isFetching, data], 256 256 ) 257 257 258 - const checkForNew = React.useCallback(async () => { 258 + const checkForNew = useNonReactiveCallback(async () => { 259 + if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) { 260 + return 261 + } 262 + 259 263 // Discover always has fresh content 260 264 if (feedUriOrActorDid === DISCOVER_FEED_URI) { 261 - return onHasNew?.(true) 265 + return onHasNew(true) 262 266 } 263 267 264 - if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) { 265 - return 266 - } 267 268 try { 268 269 if (await pollLatest(data.pages[0])) { 269 270 if (isEmpty) { ··· 275 276 } catch (e) { 276 277 logger.error('Poll latest failed', {feed, message: String(e)}) 277 278 } 278 - }, [ 279 - feed, 280 - data, 281 - isFetching, 282 - isEmpty, 283 - onHasNew, 284 - enabled, 285 - disablePoll, 286 - refetch, 287 - feedUriOrActorDid, 288 - ]) 279 + }) 289 280 290 281 const myDid = currentAccount?.did || '' 291 - const onPostCreated = React.useCallback(() => { 282 + const onPostCreated = useCallback(() => { 292 283 // NOTE 293 284 // only invalidate if there's 1 page 294 285 // more than 1 page can trigger some UI freakouts on iOS and android ··· 301 292 queryClient.invalidateQueries({queryKey: RQKEY(feed)}) 302 293 } 303 294 }, [queryClient, feed, data, myDid]) 304 - React.useEffect(() => { 295 + useEffect(() => { 305 296 return listenPostCreated(onPostCreated) 306 297 }, [onPostCreated]) 307 298 308 - React.useEffect(() => { 309 - // we store the interval handler in a ref to avoid needless 310 - // reassignments in other effects 311 - checkForNewRef.current = checkForNew 312 - }, [checkForNew]) 313 - React.useEffect(() => { 299 + useEffect(() => { 314 300 if (enabled && !disablePoll) { 315 301 const timeSinceFirstLoad = Date.now() - lastFetchRef.current 316 - if ( 317 - (isEmpty || timeSinceFirstLoad > CHECK_LATEST_AFTER) && 318 - checkForNewRef.current 319 - ) { 302 + if (isEmpty || timeSinceFirstLoad > CHECK_LATEST_AFTER) { 320 303 // check for new on enable (aka on focus) 321 - checkForNewRef.current() 304 + checkForNew() 322 305 } 323 306 } 324 - }, [enabled, disablePoll, feed, queryClient, scrollElRef, isEmpty]) 325 - React.useEffect(() => { 307 + }, [enabled, isEmpty, disablePoll, checkForNew]) 308 + 309 + useEffect(() => { 326 310 let cleanup1: () => void | undefined, cleanup2: () => void | undefined 327 311 const subscription = AppState.addEventListener('change', nextAppState => { 328 312 // check for new on app foreground 329 313 if (nextAppState === 'active') { 330 - checkForNewRef.current?.() 314 + checkForNew() 331 315 } 332 316 }) 333 317 cleanup1 = () => subscription.remove() 334 318 if (pollInterval) { 335 319 // check for new on interval 336 - const i = setInterval(() => checkForNewRef.current?.(), pollInterval) 320 + const i = setInterval(() => { 321 + checkForNew() 322 + }, pollInterval) 337 323 cleanup2 = () => clearInterval(i) 338 324 } 339 325 return () => { 340 326 cleanup1?.() 341 327 cleanup2?.() 342 328 } 343 - }, [pollInterval]) 329 + }, [pollInterval, checkForNew]) 344 330 345 331 const followProgressGuide = useProgressGuide('follow-10') 346 332 const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') ··· 350 336 351 337 const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings() 352 338 353 - const feedItems: FeedRow[] = React.useMemo(() => { 339 + const feedItems: FeedRow[] = useMemo(() => { 354 340 // wraps a slice item, and replaces it with a showLessFollowup item 355 341 // if the user has pressed show less on it 356 342 const sliceItem = (row: Extract<FeedRow, {type: 'sliceItem'}>) => { ··· 407 393 for (const page of data.pages) { 408 394 for (const slice of page.slices) { 409 395 const item = slice.items.find( 396 + // eslint-disable-next-line @typescript-eslint/no-shadow 410 397 item => item.uri === slice.feedPostUri, 411 398 ) 412 399 if (item && AppBskyEmbedVideo.isView(item.post.embed)) { ··· 599 586 // events 600 587 // = 601 588 602 - const onRefresh = React.useCallback(async () => { 589 + const onRefresh = useCallback(async () => { 603 590 logEvent('feed:refresh', { 604 591 feedType: feedType, 605 592 feedUrl: feed, ··· 615 602 setIsPTRing(false) 616 603 }, [refetch, setIsPTRing, onHasNew, feed, feedType]) 617 604 618 - const onEndReached = React.useCallback(async () => { 605 + const onEndReached = useCallback(async () => { 619 606 if (isFetching || !hasNextPage || isError) return 620 607 621 608 logEvent('feed:endReached', { ··· 638 625 feedItems.length, 639 626 ]) 640 627 641 - const onPressTryAgain = React.useCallback(() => { 628 + const onPressTryAgain = useCallback(() => { 642 629 refetch() 643 630 onHasNew?.(false) 644 631 }, [refetch, onHasNew]) 645 632 646 - const onPressRetryLoadMore = React.useCallback(() => { 633 + const onPressRetryLoadMore = useCallback(() => { 647 634 fetchNextPage() 648 635 }, [fetchNextPage]) 649 636 650 637 // rendering 651 638 // = 652 639 653 - const renderItem = React.useCallback( 640 + const renderItem = useCallback( 654 641 ({item: row, index: rowIndex}: ListRenderItemInfo<FeedRow>) => { 655 642 if (row.type === 'empty') { 656 643 return renderEmptyState() ··· 773 760 774 761 const shouldRenderEndOfFeed = 775 762 !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed 776 - const FeedFooter = React.useCallback(() => { 763 + const FeedFooter = useCallback(() => { 777 764 /** 778 765 * A bit of padding at the bottom of the feed as you scroll and when you 779 766 * reach the end, so that content isn't cut off by the bottom of the
+55 -50
src/view/com/util/load-latest/LoadLatestBtn.tsx
··· 1 - import {StyleSheet, View} from 'react-native' 1 + import {StyleSheet} from 'react-native' 2 2 import Animated from 'react-native-reanimated' 3 3 import {useSafeAreaInsets} from 'react-native-safe-area-context' 4 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 4 import {useMediaQuery} from 'react-responsive' 6 5 7 6 import {HITSLOP_20} from '#/lib/constants' 8 7 import {PressableScale} from '#/lib/custom-animations/PressableScale' 9 8 import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform' 10 - import {usePalette} from '#/lib/hooks/usePalette' 11 9 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 12 10 import {clamp} from '#/lib/numbers' 13 11 import {useGate} from '#/lib/statsig/statsig' 14 - import {colors} from '#/lib/styles' 15 12 import {useSession} from '#/state/session' 16 - import {atoms as a, useLayoutBreakpoints} from '#/alf' 13 + import {atoms as a, useLayoutBreakpoints, useTheme, web} from '#/alf' 14 + import {useInteractionState} from '#/components/hooks/useInteractionState' 15 + import {ArrowTop_Stroke2_Corner0_Rounded as ArrowIcon} from '#/components/icons/Arrow' 16 + import {CENTER_COLUMN_OFFSET} from '#/components/Layout' 17 + import {SubtleWebHover} from '#/components/SubtleWebHover' 17 18 18 19 export function LoadLatestBtn({ 19 20 onPress, ··· 24 25 label: string 25 26 showIndicator: boolean 26 27 }) { 27 - const pal = usePalette('default') 28 28 const {hasSession} = useSession() 29 29 const {isDesktop, isTablet, isMobile, isTabletOrMobile} = useWebMediaQueries() 30 30 const {centerColumnOffset} = useLayoutBreakpoints() 31 31 const fabMinimalShellTransform = useMinimalShellFabTransform() 32 32 const insets = useSafeAreaInsets() 33 + const t = useTheme() 34 + const { 35 + state: hovered, 36 + onIn: onHoverIn, 37 + onOut: onHoverOut, 38 + } = useInteractionState() 33 39 34 40 // move button inline if it starts overlapping the left nav 35 41 const isTallViewport = useMediaQuery({minHeight: 700}) ··· 48 54 : {bottom: clamp(insets.bottom, 15, 60) + 15} 49 55 50 56 return ( 51 - <Animated.View style={[showBottomBar && fabMinimalShellTransform]}> 57 + <Animated.View 58 + testID="loadLatestBtn" 59 + style={[ 60 + a.fixed, 61 + a.z_20, 62 + {left: 18}, 63 + isDesktop && 64 + (isTallViewport 65 + ? styles.loadLatestOutOfLine 66 + : styles.loadLatestInline), 67 + isTablet && 68 + (centerColumnOffset 69 + ? styles.loadLatestInlineOffset 70 + : styles.loadLatestInline), 71 + bottomPosition, 72 + showBottomBar && fabMinimalShellTransform, 73 + ]}> 52 74 <PressableScale 53 75 style={[ 54 - styles.loadLatest, 55 - isDesktop && 56 - (isTallViewport 57 - ? styles.loadLatestOutOfLine 58 - : styles.loadLatestInline), 59 - isTablet && 60 - (centerColumnOffset 61 - ? styles.loadLatestInlineOffset 62 - : styles.loadLatestInline), 63 - pal.borderDark, 64 - pal.view, 65 - bottomPosition, 76 + { 77 + width: 42, 78 + height: 42, 79 + }, 80 + a.rounded_full, 81 + a.align_center, 82 + a.justify_center, 83 + a.border, 84 + t.atoms.border_contrast_low, 85 + showIndicator ? {backgroundColor: t.palette.primary_50} : t.atoms.bg, 66 86 ]} 67 87 onPress={onPress} 68 88 hitSlop={HITSLOP_20} 69 89 accessibilityLabel={label} 70 90 accessibilityHint="" 71 - targetScale={0.9}> 72 - <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} /> 73 - {showIndicator && <View style={[styles.indicator, pal.borderDark]} />} 91 + targetScale={0.9} 92 + onPointerEnter={onHoverIn} 93 + onPointerLeave={onHoverOut}> 94 + <SubtleWebHover hover={hovered} style={[a.rounded_full]} /> 95 + <ArrowIcon 96 + size="md" 97 + style={[ 98 + a.z_10, 99 + showIndicator 100 + ? {color: t.palette.primary_500} 101 + : t.atoms.text_contrast_medium, 102 + ]} 103 + /> 74 104 </PressableScale> 75 105 </Animated.View> 76 106 ) 77 107 } 78 108 79 109 const styles = StyleSheet.create({ 80 - loadLatest: { 81 - zIndex: 20, 82 - ...a.fixed, 83 - left: 18, 84 - borderWidth: StyleSheet.hairlineWidth, 85 - width: 52, 86 - height: 52, 87 - borderRadius: 26, 88 - flexDirection: 'row', 89 - alignItems: 'center', 90 - justifyContent: 'center', 91 - }, 92 110 loadLatestInline: { 93 - // @ts-expect-error web only 94 - left: 'calc(50vw - 282px)', 111 + left: web('calc(50vw - 282px)'), 95 112 }, 96 113 loadLatestInlineOffset: { 97 - // @ts-expect-error web only 98 - left: 'calc(50vw - 432px)', 114 + left: web(`calc(50vw - 282px + ${CENTER_COLUMN_OFFSET}px)`), 99 115 }, 100 116 loadLatestOutOfLine: { 101 - // @ts-expect-error web only 102 - left: 'calc(50vw - 382px)', 103 - }, 104 - indicator: { 105 - position: 'absolute', 106 - top: 3, 107 - right: 3, 108 - backgroundColor: colors.blue3, 109 - width: 12, 110 - height: 12, 111 - borderRadius: 6, 112 - borderWidth: 1, 117 + left: web('calc(50vw - 382px)'), 113 118 }, 114 119 })