Prototype: remember your place in following feed
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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'])
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user