Add "See more posts" button
This commit is contained in:
@@ -199,17 +199,11 @@ export type Events = {
|
||||
reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
|
||||
}
|
||||
'feed:resume:toggle': {
|
||||
/** The state the user switched the setting to. */
|
||||
enabled: boolean
|
||||
}
|
||||
'feed:resume:seeNewPostsPressed': {}
|
||||
'feed:positionRestored': {
|
||||
/**
|
||||
* `restored` means we scrolled to the anchor post; `not-found` means the
|
||||
* anchor never showed up within the page cap; `aborted` means the user
|
||||
* scrolled or refreshed before the restore could complete.
|
||||
*/
|
||||
outcome: 'restored' | 'not-found' | 'aborted'
|
||||
/** Number of rows above the restored anchor, when restored. */
|
||||
depth?: number
|
||||
pagesFetched: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import {useCallback} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
FadeOut,
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {useHomeHeaderMode} from '#/view/com/util/MainScrollProvider'
|
||||
import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon} from '#/components/icons/Arrow'
|
||||
import {CENTER_COLUMN_OFFSET} from '#/components/Layout'
|
||||
import {SubtleHover} from '#/components/SubtleHover'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
/**
|
||||
* Floating pill shown near the top of a feed after the last read position was
|
||||
* restored, telling the user there are newer posts loaded above. Pressing it
|
||||
* scrolls to the top of the feed.
|
||||
*/
|
||||
export function SeeNewPostsPill({
|
||||
onPress: onPressInner,
|
||||
topOffset = 0,
|
||||
}: {
|
||||
onPress: () => void
|
||||
/**
|
||||
* Height of the floating feed header the pill must clear. Pass the same
|
||||
* headerOffset used by the feed list.
|
||||
*/
|
||||
topOffset?: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const playHaptic = useHaptics()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {centerColumnOffset} = useLayoutBreakpoints()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
|
||||
/*
|
||||
* On mobile (and native) topOffset is the floating header height, so a
|
||||
* small margin below it is enough. On larger web breakpoints the header
|
||||
* offset is 0 but the tab bar is sticky, so clear its height instead.
|
||||
*/
|
||||
const top = topOffset > 0 ? topOffset + 12 : gtMobile ? 64 : 12
|
||||
|
||||
const headerMode = useHomeHeaderMode()
|
||||
const {headerHeight} = useShellLayout()
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
const headerPinnedHeight = IS_LIQUID_GLASS ? topInset : 0
|
||||
|
||||
/*
|
||||
* On mobile the floating header minimizes away as the user scrolls, so
|
||||
* follow its translation to stay just below it - but stop at the safe
|
||||
* area inset so the pill never sits under the status bar.
|
||||
*/
|
||||
const followHeaderStyle = useAnimatedStyle(() => {
|
||||
if (topOffset === 0) {
|
||||
return {transform: [{translateY: 0}]}
|
||||
}
|
||||
const translateY = interpolate(
|
||||
headerMode.get(),
|
||||
[0, 1],
|
||||
[0, headerPinnedHeight - headerHeight.get()],
|
||||
)
|
||||
const minTop = topInset + 12
|
||||
return {
|
||||
transform: [{translateY: Math.max(translateY, minTop - top)}],
|
||||
}
|
||||
})
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
scheduleOnRN(playHaptic)
|
||||
onPressInner?.()
|
||||
}, [onPressInner, playHaptic])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.z_20,
|
||||
/*
|
||||
* On web the pill is fixed and must be centered on the content
|
||||
* column, not the viewport - the column shifts at some widths (nav
|
||||
* rail, tablet offset), so mirror Layout's WebCenterBorders
|
||||
* centering. On native the feed spans the screen, so a full-width
|
||||
* absolute container can simply center its child.
|
||||
*/
|
||||
IS_WEB
|
||||
? [
|
||||
a.fixed,
|
||||
{
|
||||
left: '50%',
|
||||
transform: [
|
||||
{translateX: '-50%'},
|
||||
{
|
||||
translateX: centerColumnOffset ? CENTER_COLUMN_OFFSET : 0,
|
||||
},
|
||||
...a.scrollbar_offset.transform,
|
||||
],
|
||||
},
|
||||
]
|
||||
: [a.absolute, a.w_full, a.align_center],
|
||||
{
|
||||
top,
|
||||
// Don't prevent scrolling in this area _except_ for in the pill itself
|
||||
pointerEvents: 'box-none',
|
||||
},
|
||||
]}>
|
||||
{/*
|
||||
* The header-follow translation lives on its own wrapper because both
|
||||
* the container (web centering) and the pressable (press scale) already
|
||||
* use transforms of their own.
|
||||
*/}
|
||||
<Animated.View style={[followHeaderStyle, {pointerEvents: 'box-none'}]}>
|
||||
<AnimatedPressable
|
||||
testID="seeNewPostsPill"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`See new posts`}
|
||||
accessibilityHint={l`Scrolls to the top of the feed`}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.gap_xs,
|
||||
a.rounded_full,
|
||||
a.shadow_sm,
|
||||
a.border,
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
{
|
||||
backgroundColor: t.palette.primary_50,
|
||||
pointerEvents: 'box-only',
|
||||
},
|
||||
]}
|
||||
entering={FadeInDown.springify().damping(18).stiffness(220)}
|
||||
exiting={FadeOut.duration(150)}
|
||||
onPress={onPress}
|
||||
onPointerEnter={onHoverIn}
|
||||
onPointerLeave={onHoverOut}>
|
||||
<SubtleHover hover={hovered} style={[a.rounded_full]} />
|
||||
<ArrowUpIcon
|
||||
size="xs"
|
||||
style={[a.z_10, {color: t.palette.primary_600}]}
|
||||
/>
|
||||
<Text style={[a.z_10, a.font_bold, {color: t.palette.primary_600}]}>
|
||||
<Trans>See new posts</Trans>
|
||||
</Text>
|
||||
</AnimatedPressable>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -32,25 +32,33 @@ export function useFollowingFeedResumeEnabled() {
|
||||
|
||||
/**
|
||||
* Save the most recently viewed post in the Following feed as the anchor to
|
||||
* restore to on next cold start.
|
||||
* restore to on next cold start, along with the newest post the user has
|
||||
* seen at the top of the feed (if known).
|
||||
*/
|
||||
export function saveFollowingFeedPosition(did: string, anchorUri: string) {
|
||||
export function saveFollowingFeedPosition(
|
||||
did: string,
|
||||
anchorUri: string,
|
||||
seenHeadUri?: string,
|
||||
) {
|
||||
account.set([did, 'followingFeedPosition'], {
|
||||
anchorUri,
|
||||
seenHeadUri,
|
||||
savedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the saved anchor post URI, or undefined if nothing was saved or the
|
||||
* Returns the saved feed position, or undefined if nothing was saved or the
|
||||
* saved position has expired.
|
||||
*/
|
||||
export function getFollowingFeedPosition(did: string): string | undefined {
|
||||
export function getFollowingFeedPosition(
|
||||
did: string,
|
||||
): {anchorUri: string; seenHeadUri?: string} | undefined {
|
||||
const position = account.get([did, 'followingFeedPosition'])
|
||||
if (!position || Date.now() - position.savedAt > MAX_POSITION_AGE) {
|
||||
return undefined
|
||||
}
|
||||
return position.anchorUri
|
||||
return position
|
||||
}
|
||||
|
||||
export function clearFollowingFeedPosition(did: string) {
|
||||
|
||||
@@ -106,6 +106,12 @@ export type Account = {
|
||||
followingFeedPosition?: {
|
||||
anchorUri: string
|
||||
savedAt: number
|
||||
/**
|
||||
* The newest post the user has actually seen at the top of the feed.
|
||||
* Used after a restore to decide whether there is anything new above
|
||||
* worth pointing out.
|
||||
*/
|
||||
seenHeadUri?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,7 @@ import {type ListMethods} from '#/view/com/util/List'
|
||||
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
|
||||
import {MainScrollProvider} from '#/view/com/util/MainScrollProvider'
|
||||
import {useTheme} from '#/alf'
|
||||
import {SeeNewPostsPill} from '#/components/feeds/SeeNewPostsPill'
|
||||
import {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
|
||||
import {EditBig_Stroke2_Corner2_Rounded as EditBigIcon} from '#/components/icons/EditBig'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
@@ -74,6 +75,13 @@ export function FeedPage({
|
||||
const feedFeedback = useFeedFeedback(feedInfo, hasSession)
|
||||
const scrollElRef = useRef<ListMethods>(null)
|
||||
const [hasNew, setHasNew] = useState(false)
|
||||
/**
|
||||
* Whether to show the "See new posts" pill after the feed was restored to
|
||||
* the last read position. Cleared once the user returns to the top, either
|
||||
* by pressing the pill or by scrolling up on their own.
|
||||
*/
|
||||
const [showResumePill, setShowResumePill] = useState(false)
|
||||
const wasScrolledDownRef = useRef(false)
|
||||
const setHomeBadge = useSetHomeBadge()
|
||||
const isVideoFeed = useMemo(() => {
|
||||
const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri)
|
||||
@@ -97,6 +105,23 @@ export function FeedPage({
|
||||
})
|
||||
}, [headerOffset])
|
||||
|
||||
/*
|
||||
* The pill renders only while scrolled down, but it should not come back
|
||||
* on later scroll-downs, so clear it for good once the user has scrolled
|
||||
* back up to the top.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isScrolledDown) {
|
||||
wasScrolledDownRef.current = true
|
||||
} else if (wasScrolledDownRef.current) {
|
||||
setShowResumePill(false)
|
||||
}
|
||||
}, [isScrolledDown])
|
||||
|
||||
const onPositionRestored = useCallback(() => {
|
||||
setShowResumePill(true)
|
||||
}, [])
|
||||
|
||||
const onSoftReset = useCallback(() => {
|
||||
const isScreenFocused =
|
||||
getTabState(getRootNavigation(navigation).getState(), 'Home') ===
|
||||
@@ -150,8 +175,34 @@ export function FeedPage({
|
||||
})
|
||||
}, [ax, scrollToTop, feed, queryClient, currentAccount])
|
||||
|
||||
/*
|
||||
* In the restore case the posts above are already loaded, so pressing the
|
||||
* pill only needs to scroll up. In the hasNew case they are not loaded
|
||||
* yet, so it must refetch like the "Load new posts" button.
|
||||
*/
|
||||
const onPressSeeNewPosts = useCallback(() => {
|
||||
setShowResumePill(false)
|
||||
if (hasNew) {
|
||||
onPressLoadLatest()
|
||||
} else {
|
||||
scrollToTop()
|
||||
ax.metric('feed:resume:seeNewPostsPressed', {})
|
||||
}
|
||||
}, [ax, hasNew, onPressLoadLatest, scrollToTop])
|
||||
|
||||
const shouldPrefetch = IS_NATIVE && isPageAdjacent
|
||||
const isDiscoverFeed = feedInfo.uri === DISCOVER_FEED_URI
|
||||
const isFollowingFeed = feed === 'following'
|
||||
/*
|
||||
* On the Following feed the pill takes over signaling new posts from the
|
||||
* LoadLatestBtn indicator, both when new posts arrive while reading
|
||||
* (hasNew) and after restoring the last read position. Other feeds keep
|
||||
* the LoadLatestBtn indicator. Only shown while scrolled down - once the
|
||||
* top of the feed is in view the pill would just be noise, so it hides
|
||||
* even if hasNew is still set.
|
||||
*/
|
||||
const showSeeNewPostsPill =
|
||||
isFollowingFeed && isScrolledDown && (hasNew || showResumePill)
|
||||
return (
|
||||
<View
|
||||
testID={testID}
|
||||
@@ -169,6 +220,7 @@ export function FeedPage({
|
||||
scrollElRef={scrollElRef}
|
||||
onScrolledDownChange={setIsScrolledDown}
|
||||
onHasNew={setHasNew}
|
||||
onPositionRestored={onPositionRestored}
|
||||
renderEmptyState={renderEmptyState}
|
||||
renderEndOfFeed={renderEndOfFeed}
|
||||
headerOffset={headerOffset}
|
||||
@@ -177,11 +229,17 @@ export function FeedPage({
|
||||
/>
|
||||
</FeedFeedbackProvider>
|
||||
</MainScrollProvider>
|
||||
{(isScrolledDown || hasNew) && (
|
||||
{showSeeNewPostsPill && (
|
||||
<SeeNewPostsPill
|
||||
onPress={onPressSeeNewPosts}
|
||||
topOffset={headerOffset}
|
||||
/>
|
||||
)}
|
||||
{(isScrolledDown || (hasNew && !isFollowingFeed)) && (
|
||||
<LoadLatestBtn
|
||||
onPress={onPressLoadLatest}
|
||||
label={_(msg`Load new posts`)}
|
||||
showIndicator={hasNew}
|
||||
showIndicator={hasNew && !isFollowingFeed}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -224,6 +224,18 @@ const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY
|
||||
*/
|
||||
const RESTORE_MAX_PAGES = 6
|
||||
|
||||
/**
|
||||
* URI of the first post in the feed, i.e. the newest loaded post.
|
||||
*/
|
||||
function getHeadUri(feedItems: FeedRow[]): string | undefined {
|
||||
for (const row of feedItems) {
|
||||
if (row.type === 'sliceItem') {
|
||||
return row.slice.items[row.indexInSlice].uri
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
let PostFeed = ({
|
||||
feed,
|
||||
description,
|
||||
@@ -236,6 +248,7 @@ let PostFeed = ({
|
||||
scrollElRef,
|
||||
onScrolledDownChange,
|
||||
onHasNew,
|
||||
onPositionRestored,
|
||||
renderEmptyState,
|
||||
renderEndOfFeed,
|
||||
testID,
|
||||
@@ -260,6 +273,11 @@ let PostFeed = ({
|
||||
scrollElRef?: ListRef
|
||||
onHasNew?: (v: boolean) => void
|
||||
onScrolledDownChange?: (isScrolledDown: boolean) => void
|
||||
/**
|
||||
* Called after the feed successfully scrolled back to the last read
|
||||
* position on cold start.
|
||||
*/
|
||||
onPositionRestored?: () => void
|
||||
renderEmptyState: () => React.ReactElement
|
||||
renderEndOfFeed?: () => React.ReactElement
|
||||
testID?: string
|
||||
@@ -379,13 +397,20 @@ let PostFeed = ({
|
||||
* overwritten before we've scrolled to it.
|
||||
*/
|
||||
// oxlint-disable-next-line react/hook-use-state
|
||||
const [restoreAnchorUri] = useState(() =>
|
||||
const [restorePosition] = useState(() =>
|
||||
feed === 'following' && resumeEnabled && currentAccount
|
||||
? getFollowingFeedPosition(currentAccount.did)
|
||||
: undefined,
|
||||
)
|
||||
const restoreAnchorUri = restorePosition?.anchorUri
|
||||
const restorePendingRef = useRef(restoreAnchorUri != null)
|
||||
const restoreScrollRetriesRef = useRef(0)
|
||||
/**
|
||||
* The newest post the user has actually seen at the top of the feed,
|
||||
* carried over from the saved position and updated whenever the head post
|
||||
* comes into view.
|
||||
*/
|
||||
const seenHeadUriRef = useRef(restorePosition?.seenHeadUri)
|
||||
|
||||
const myDid = currentAccount?.did || ''
|
||||
const onPostCreated = useCallback(() => {
|
||||
@@ -794,7 +819,8 @@ let PostFeed = ({
|
||||
*/
|
||||
if (
|
||||
isScrolledDownRef.current ||
|
||||
getFollowingFeedPosition(currentAccount.did) !== restoreAnchorUri
|
||||
getFollowingFeedPosition(currentAccount.did)?.anchorUri !==
|
||||
restoreAnchorUri
|
||||
) {
|
||||
restorePendingRef.current = false
|
||||
ax.metric('feed:positionRestored', {
|
||||
@@ -820,6 +846,15 @@ let PostFeed = ({
|
||||
depth: index,
|
||||
pagesFetched: data.pages.length,
|
||||
})
|
||||
/*
|
||||
* Only announce the restore (which surfaces the "See new posts" pill)
|
||||
* if the feed's head post is one the user hasn't seen - otherwise
|
||||
* everything above the anchor was already read.
|
||||
*/
|
||||
const headUri = getHeadUri(feedItems)
|
||||
if (headUri && headUri !== seenHeadUriRef.current) {
|
||||
onPositionRestored?.()
|
||||
}
|
||||
} else if (
|
||||
isError ||
|
||||
!hasNextPage ||
|
||||
@@ -846,6 +881,7 @@ let PostFeed = ({
|
||||
scrollElRef,
|
||||
headerOffset,
|
||||
ax,
|
||||
onPositionRestored,
|
||||
])
|
||||
|
||||
const onScrollToIndexFailed = useCallback(
|
||||
@@ -1166,9 +1202,14 @@ let PostFeed = ({
|
||||
currentAccount &&
|
||||
!restorePendingRef.current
|
||||
) {
|
||||
const uri = item.slice.items[item.indexInSlice].uri
|
||||
if (uri === getHeadUri(feedItems)) {
|
||||
seenHeadUriRef.current = uri
|
||||
}
|
||||
saveFollowingFeedPosition(
|
||||
currentAccount.did,
|
||||
item.slice.items[item.indexInSlice].uri,
|
||||
uri,
|
||||
seenHeadUriRef.current,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1283,6 +1324,7 @@ let PostFeed = ({
|
||||
ax,
|
||||
resumeEnabled,
|
||||
currentAccount,
|
||||
feedItems,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user