diff --git a/src/analytics/features/index.ts b/src/analytics/features/index.ts index 7b1d9cbbaf..ba6ddb6e78 100644 --- a/src/analytics/features/index.ts +++ b/src/analytics/features/index.ts @@ -93,6 +93,23 @@ export function getFeatureDescription(feature: Features, i18n: I18n) { }), ), } + case Features.FollowingFeedResumeEnable: + return { + key: feature, + name: i18n._( + msg({ + message: 'Remember your place', + comment: + 'Name for a feature flag (Reopen the Following feed at the last post you read.)', + }), + ), + description: i18n._( + msg({ + message: 'Reopen the Following feed at the last post you read.', + comment: 'Description of a feature flag (Remember your place)', + }), + ), + } case Features.CanonicalPostNumberingEnable: return { key: feature, diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 4e8d183c8d..8f5508d8eb 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -25,6 +25,7 @@ export enum Features { FollowSortEnable = 'follow_sort:enable', OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable', CanonicalPostNumberingEnable = 'canonical_post_numbering:enable', + FollowingFeedResumeEnable = 'following_feed:resume:enable', // values TrendingDiscoverValues = 'trending_discover:values', diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 99daeec162..cd0a8ed0fb 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -198,6 +198,21 @@ export type Events = { feedType: string reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest' } + 'feed:resume:toggle': { + /** The state the user switched the setting to. */ + enabled: boolean + } + '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 + } 'feed:save': { feedUrl: string } diff --git a/src/screens/Settings/FollowingFeedPreferences.tsx b/src/screens/Settings/FollowingFeedPreferences.tsx index 265019d563..7fdf3d9804 100644 --- a/src/screens/Settings/FollowingFeedPreferences.tsx +++ b/src/screens/Settings/FollowingFeedPreferences.tsx @@ -6,6 +6,7 @@ import { type CommonNavigatorParams, type NativeStackScreenProps, } from '#/lib/routes/types' +import {useFollowingFeedResumeEnabled} from '#/state/feed-position' import { usePreferencesQuery, useSetFeedViewPreferencesMutation, @@ -15,9 +16,11 @@ import {Admonition} from '#/components/Admonition' import * as Toggle from '#/components/forms/Toggle' import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker' import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' +import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock' import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote' import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' import * as Layout from '#/components/Layout' +import {useAnalytics} from '#/analytics' import * as SettingsList from './components/SettingsList' type Props = NativeStackScreenProps< @@ -26,6 +29,7 @@ type Props = NativeStackScreenProps< > export function FollowingFeedPreferencesScreen({}: Props) { const {_} = useLingui() + const ax = useAnalytics() const {data: preferences} = usePreferencesQuery() const {mutate: setFeedViewPref, variables} = @@ -48,6 +52,8 @@ export function FollowingFeedPreferencesScreen({}: Props) { preferences?.feedViewPrefs?.lab_mergeFeedEnabled, ) + const [resumeEnabled, setResumeEnabled] = useFollowingFeedResumeEnabled() + return ( @@ -120,6 +126,22 @@ export function FollowingFeedPreferencesScreen({}: Props) { + {ax.features.enabled(ax.features.FollowingFeedResumeEnable) && ( + + + + + Remember your place in the feed + + + + + )} diff --git a/src/state/feed-position.ts b/src/state/feed-position.ts new file mode 100644 index 0000000000..793fcb8c28 --- /dev/null +++ b/src/state/feed-position.ts @@ -0,0 +1,58 @@ +import {useSession} from '#/state/session' +import {useAnalytics} from '#/analytics' +import {account, useStorage} from '#/storage' + +/* + * Positions older than this are discarded rather than restored. Paginating + * that far back is slow, and the user has likely moved on. + */ +const MAX_POSITION_AGE = 24 * 60 * 60 * 1000 + +/** + * Whether the Following feed should restore the last read position on cold + * start. The GrowthBook gate is a hard requirement, so turning the flag off + * disables the feature (and hides its settings toggle) for everyone. Within + * the gate, the feature is on by default and the user can opt out from the + * Following feed preferences screen. + */ +export function useFollowingFeedResumeEnabled() { + const ax = useAnalytics() + const {currentAccount} = useSession() + const [enabled, setEnabled] = useStorage(account, [ + currentAccount?.did ?? '', + 'followingFeedResumeEnabled', + ]) + const gateEnabled = ax.features.enabled(ax.features.FollowingFeedResumeEnable) + const setEnabledAndTrack = (value: boolean) => { + ax.metric('feed:resume:toggle', {enabled: value}) + setEnabled(value) + } + return [gateEnabled && (enabled ?? true), setEnabledAndTrack] as const +} + +/** + * Save the most recently viewed post in the Following feed as the anchor to + * restore to on next cold start. + */ +export function saveFollowingFeedPosition(did: string, anchorUri: string) { + account.set([did, 'followingFeedPosition'], { + anchorUri, + savedAt: Date.now(), + }) +} + +/** + * Returns the saved anchor post URI, or undefined if nothing was saved or the + * saved position has expired. + */ +export function getFollowingFeedPosition(did: string): string | undefined { + const position = account.get([did, 'followingFeedPosition']) + if (!position || Date.now() - position.savedAt > MAX_POSITION_AGE) { + return undefined + } + return position.anchorUri +} + +export function clearFollowingFeedPosition(did: string) { + account.remove([did, 'followingFeedPosition']) +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 2f4424792d..9c99b79227 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -92,6 +92,22 @@ export type Account = { lastSelectedHomeFeed?: string + /** + * Whether the Following feed restores the last read position on cold start. + * Toggled in the Following feed preferences screen. On by default, but the + * feature also requires the FollowingFeedResumeEnable gate. + */ + followingFeedResumeEnabled?: boolean + + /** + * The most recently viewed post in the Following feed, used to restore the + * feed position on cold start when `followingFeedResumeEnabled` is set. + */ + followingFeedPosition?: { + anchorUri: string + savedAt: number + } + /** * Recently selected GIFs in the GIF picker. Most recent first, capped at 20. */ diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 331c62e0d1..a8445ff201 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -19,6 +19,7 @@ import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers' import {type AllNavigatorParams} from '#/lib/routes/types' import {listenSoftReset} from '#/state/events' import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' +import {clearFollowingFeedPosition} from '#/state/feed-position' import {useSetHomeBadge} from '#/state/home-badge' import {type FeedSourceInfo} from '#/state/queries/feed' import { @@ -63,7 +64,7 @@ export function FeedPage({ feedInfo: FeedSourceInfo }) { const ax = useAnalytics() - const {hasSession} = useSession() + const {hasSession, currentAccount} = useSession() const {_} = useLingui() const navigation = useNavigation>() const queryClient = useQueryClient() @@ -104,13 +105,24 @@ export function FeedPage({ scrollToTop() truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) setHasNew(false) + if (feed === 'following' && currentAccount) { + clearFollowingFeedPosition(currentAccount.did) + } ax.metric('feed:refresh', { feedType: feed.split('|')[0], feedUrl: feed, reason: 'soft-reset', }) } - }, [ax, navigation, isPageFocused, scrollToTop, queryClient, feed]) + }, [ + ax, + navigation, + isPageFocused, + scrollToTop, + queryClient, + feed, + currentAccount, + ]) // fires when page within screen is activated/deactivated useEffect(() => { @@ -128,12 +140,15 @@ export function FeedPage({ scrollToTop() truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) setHasNew(false) + if (feed === 'following' && currentAccount) { + clearFollowingFeedPosition(currentAccount.did) + } ax.metric('feed:refresh', { feedType: feed.split('|')[0], feedUrl: feed, reason: 'load-latest', }) - }, [ax, scrollToTop, feed, queryClient]) + }, [ax, scrollToTop, feed, queryClient, currentAccount]) const shouldPrefetch = IS_NATIVE && isPageAdjacent const isDiscoverFeed = feedInfo.uri === DISCOVER_FEED_URI diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index eea2453739..b0d8abcfb1 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -39,6 +39,12 @@ import {logger} from '#/logger' import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow' import {listenPostCreated} from '#/state/events' import {useFeedFeedbackContext} from '#/state/feed-feedback' +import { + clearFollowingFeedPosition, + getFollowingFeedPosition, + saveFollowingFeedPosition, + useFollowingFeedResumeEnabled, +} from '#/state/feed-position' import {useTrendingSettings} from '#/state/preferences/trending' import {STALE} from '#/state/queries' import { @@ -212,6 +218,12 @@ export type PostFeedRef = { // const REFRESH_AFTER = STALE.HOURS.ONE const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY +/* + * When restoring the last read position in the Following feed, fetch at most + * this many pages while looking for the anchor post before giving up. + */ +const RESTORE_MAX_PAGES = 6 + let PostFeed = ({ feed, description, @@ -359,6 +371,22 @@ let PostFeed = ({ onScrolledDownChange?.(isScrolledDown) } + const [resumeEnabled] = useFollowingFeedResumeEnabled() + /** + * The post to restore the Following feed position to, read once at mount. + * Restoration is pending until the anchor is found (or we give up); + * position saves are paused while pending so the stored anchor isn't + * overwritten before we've scrolled to it. + */ + // oxlint-disable-next-line react/hook-use-state + const [restoreAnchorUri] = useState(() => + feed === 'following' && resumeEnabled && currentAccount + ? getFollowingFeedPosition(currentAccount.did) + : undefined, + ) + const restorePendingRef = useRef(restoreAnchorUri != null) + const restoreScrollRetriesRef = useRef(0) + const myDid = currentAccount?.did || '' const onPostCreated = useCallback(() => { // NOTE @@ -749,6 +777,101 @@ let PostFeed = ({ trendingIndices, ]) + /* + * Restore the last read position on cold start. The anchor post may be + * several pages down, so keep fetching (up to RESTORE_MAX_PAGES) until it + * shows up in the feed, then scroll to it. + */ + useEffect(() => { + if (!restorePendingRef.current || !restoreAnchorUri || !currentAccount) { + return + } + if (!data) return + /* + * Bail if the user already scrolled away, or if an explicit refresh + * (soft reset, pull-to-refresh, "Load new posts") cleared the saved + * position while we were looking for the anchor. + */ + if ( + isScrolledDownRef.current || + getFollowingFeedPosition(currentAccount.did) !== restoreAnchorUri + ) { + restorePendingRef.current = false + ax.metric('feed:positionRestored', { + outcome: 'aborted', + pagesFetched: data.pages.length, + }) + return + } + const index = feedItems.findIndex( + row => + row.type === 'sliceItem' && + row.slice.items[row.indexInSlice].uri === restoreAnchorUri, + ) + if (index >= 0) { + restorePendingRef.current = false + scrollElRef?.current?.scrollToIndex({ + index, + animated: false, + viewOffset: headerOffset, + }) + ax.metric('feed:positionRestored', { + outcome: 'restored', + depth: index, + pagesFetched: data.pages.length, + }) + } else if ( + isError || + !hasNextPage || + data.pages.length >= RESTORE_MAX_PAGES + ) { + // the anchor post is gone or too far down - leave the user at the top + restorePendingRef.current = false + ax.metric('feed:positionRestored', { + outcome: 'not-found', + pagesFetched: data.pages.length, + }) + } else if (!isFetchingNextPage) { + void fetchNextPage() + } + }, [ + restoreAnchorUri, + currentAccount, + data, + feedItems, + isError, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + scrollElRef, + headerOffset, + ax, + ]) + + const onScrollToIndexFailed = useCallback( + (info: {index: number; averageItemLength: number}) => { + /* + * Native FlatList can't scroll to an index it hasn't rendered yet. Jump + * to an estimated offset, then retry once the region has rendered. + */ + scrollElRef?.current?.scrollToOffset({ + offset: info.averageItemLength * info.index, + animated: false, + }) + if (restoreScrollRetriesRef.current < 3) { + restoreScrollRetriesRef.current += 1 + setTimeout(() => { + scrollElRef?.current?.scrollToIndex({ + index: info.index, + animated: false, + viewOffset: headerOffset, + }) + }, 100) + } + }, + [scrollElRef, headerOffset], + ) + // events // = // @@ -756,6 +879,9 @@ let PostFeed = ({ const refreshFeed = async () => { if (!enabled) return + if (feed === 'following' && currentAccount) { + clearFollowingFeedPosition(currentAccount.did) + } ax.metric('feed:refresh', { feedType: feedType, feedUrl: feed, @@ -1028,6 +1154,24 @@ let PostFeed = ({ (item: FeedRow) => { feedFeedback.onItemSeen(item) + /* + * Track the most recently viewed post as the anchor to restore to on + * next cold start. Paused while a restore is pending so the target + * isn't overwritten by posts seen at the top of the feed. + */ + if ( + item.type === 'sliceItem' && + feed === 'following' && + resumeEnabled && + currentAccount && + !restorePendingRef.current + ) { + saveFollowingFeedPosition( + currentAccount.did, + item.slice.items[item.indexInSlice].uri, + ) + } + // Events that should fire exactly once for every new post, regardless of // its position within a slice or video grid row. const onPostSeen = (post: AppBskyFeedDefs.PostView) => { @@ -1131,7 +1275,15 @@ let PostFeed = ({ } } }, - [feedFeedback, feed, liveNowConfig, getPostPosition, ax], + [ + feedFeedback, + feed, + liveNowConfig, + getPostPosition, + ax, + resumeEnabled, + currentAccount, + ], ) return ( @@ -1154,6 +1306,7 @@ let PostFeed = ({ onScrolledDownChange={handleScrolledDownChange} onEndReached={() => void onEndReached()} onEndReachedThreshold={2} // number of posts left to trigger load more + onScrollToIndexFailed={IS_NATIVE ? onScrollToIndexFailed : undefined} removeClippedSubviews={true} extraData={extraData} desktopFixedHeight={