Prototype: remember your place in following feed

This commit is contained in:
Alex Benzer
2026-08-12 13:39:06 -07:00
parent 3d736c76b7
commit 6d8157ff62
8 changed files with 301 additions and 4 deletions
+17
View File
@@ -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,
+1
View File
@@ -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',
+15
View File
@@ -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
}
@@ -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 (
<Layout.Screen testID="followingFeedPreferencesScreen">
<Layout.Header.Outer>
@@ -120,6 +126,22 @@ export function FollowingFeedPreferencesScreen({}: Props) {
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
{ax.features.enabled(ax.features.FollowingFeedResumeEnable) && (
<Toggle.Item
type="checkbox"
name="remember-place"
label={_(msg`Remember your place in the feed`)}
value={resumeEnabled}
onChange={setResumeEnabled}>
<SettingsList.Item>
<SettingsList.ItemIcon icon={ClockIcon} />
<SettingsList.ItemText>
<Trans>Remember your place in the feed</Trans>
</SettingsList.ItemText>
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
)}
<SettingsList.Divider />
<SettingsList.Group>
<SettingsList.ItemIcon icon={BeakerIcon} />
+58
View File
@@ -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'])
}
+16
View File
@@ -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.
*/
+18 -3
View File
@@ -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<NavigationProp<AllNavigatorParams>>()
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
+154 -1
View File
@@ -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={