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, ], )