diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts
index cd0a8ed0fb..cfcaa6ab95 100644
--- a/src/analytics/metrics/types.ts
+++ b/src/analytics/metrics/types.ts
@@ -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
}
diff --git a/src/components/feeds/SeeNewPostsPill.tsx b/src/components/feeds/SeeNewPostsPill.tsx
new file mode 100644
index 0000000000..53dfa30cdc
--- /dev/null
+++ b/src/components/feeds/SeeNewPostsPill.tsx
@@ -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 (
+
+ {/*
+ * 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.
+ */}
+
+
+
+
+
+ See new posts
+
+
+
+
+ )
+}
diff --git a/src/state/feed-position.ts b/src/state/feed-position.ts
index 793fcb8c28..fb4da125f6 100644
--- a/src/state/feed-position.ts
+++ b/src/state/feed-position.ts
@@ -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) {
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
index 9c99b79227..121fd8182e 100644
--- a/src/storage/schema.ts
+++ b/src/storage/schema.ts
@@ -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
}
/**
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index a8445ff201..1dcbac112f 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -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(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 (
- {(isScrolledDown || hasNew) && (
+ {showSeeNewPostsPill && (
+
+ )}
+ {(isScrolledDown || (hasNew && !isFollowingFeed)) && (
)}
diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx
index b0d8abcfb1..cee5475ca5 100644
--- a/src/view/com/posts/PostFeed.tsx
+++ b/src/view/com/posts/PostFeed.tsx
@@ -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,
],
)