Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Traffic reduction and tuned caching strats (#2215)

* Update the feed to only check latest on focus after 30s, but to do a full reset on focus after 1 hour to avoid very stale data

* Remove the isFeedPublic query

* Fix: avoid double next-page fetches

* Reduce some poll intervals to reduce server load

* Guard against double-fires of fetchNextPage

* Reduce polling on blurred screens

authored by

Paul Frazee and committed by
GitHub
2a712630 dd074371

+82 -150
-45
src/state/queries/feed.ts
··· 161 161 }) 162 162 } 163 163 164 - export const isFeedPublicQueryKey = ({uri}: {uri: string}) => [ 165 - 'isFeedPublic', 166 - uri, 167 - ] 168 - 169 - export function useIsFeedPublicQuery({uri}: {uri: string}) { 170 - return useQuery({ 171 - queryKey: isFeedPublicQueryKey({uri}), 172 - queryFn: async ({queryKey}) => { 173 - const [, uri] = queryKey 174 - try { 175 - const res = await getAgent().app.bsky.feed.getFeed({ 176 - feed: uri, 177 - limit: 1, 178 - }) 179 - return { 180 - isPublic: Boolean(res.data.feed), 181 - error: undefined, 182 - } 183 - } catch (e: any) { 184 - /** 185 - * This should be an `XRPCError`, but I can't safely import from 186 - * `@atproto/xrpc` due to a depdency on node's `crypto` module. 187 - * 188 - * @see https://github.com/bluesky-social/atproto/blob/c17971a2d8e424cc7f10c071d97c07c08aa319cf/packages/xrpc/src/client.ts#L126 189 - */ 190 - if (e?.status === 401) { 191 - return { 192 - isPublic: false, 193 - error: e, 194 - } 195 - } 196 - 197 - /* 198 - * Non-401 response means something else went wrong on the server 199 - */ 200 - return { 201 - isPublic: true, 202 - error: e, 203 - } 204 - } 205 - }, 206 - }) 207 - } 208 - 209 164 export const useGetPopularFeedsQueryKey = ['getPopularFeeds'] 210 165 211 166 export function useGetPopularFeedsQuery() {
+16 -13
src/state/queries/notifications/feed.ts
··· 16 16 * 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead. 17 17 */ 18 18 19 - import {useEffect} from 'react' 19 + import {useEffect, useRef} from 'react' 20 20 import {AppBskyFeedDefs} from '@atproto/api' 21 21 import { 22 22 useInfiniteQuery, ··· 49 49 const threadMutes = useMutedThreads() 50 50 const unreads = useUnreadNotificationsApi() 51 51 const enabled = opts?.enabled !== false 52 + const lastPageCountRef = useRef(0) 52 53 53 54 const query = useInfiniteQuery< 54 55 FeedPage, ··· 104 105 105 106 useEffect(() => { 106 107 const {isFetching, hasNextPage, data} = query 108 + if (isFetching || !hasNextPage) { 109 + return 110 + } 107 111 112 + // avoid double-fires of fetchNextPage() 113 + if ( 114 + lastPageCountRef.current !== 0 && 115 + lastPageCountRef.current === data?.pages?.length 116 + ) { 117 + return 118 + } 119 + 120 + // fetch next page if we haven't gotten a full page of content 108 121 let count = 0 109 - let numEmpties = 0 110 122 for (const page of data?.pages || []) { 111 - if (!page.items.length) { 112 - numEmpties++ 113 - } 114 123 count += page.items.length 115 124 } 116 - 117 - if ( 118 - !isFetching && 119 - hasNextPage && 120 - count < PAGE_SIZE && 121 - numEmpties < 3 && 122 - (data?.pages.length || 0) < 6 123 - ) { 125 + if (count < PAGE_SIZE && (data?.pages.length || 0) < 6) { 124 126 query.fetchNextPage() 127 + lastPageCountRef.current = data?.pages?.length || 0 125 128 } 126 129 }, [query]) 127 130
+21 -14
src/state/queries/post-feed.ts
··· 1 - import React, {useCallback, useEffect} from 'react' 1 + import React, {useCallback, useEffect, useRef} from 'react' 2 2 import { 3 3 AppBskyFeedDefs, 4 4 AppBskyFeedPost, ··· 78 78 api: FeedAPI 79 79 cursor: string | undefined 80 80 feed: AppBskyFeedDefs.FeedViewPost[] 81 + fetchedAt: number 81 82 } 82 83 83 84 export interface FeedPage { ··· 85 86 tuner: FeedTuner | NoopFeedTuner 86 87 cursor: string | undefined 87 88 slices: FeedPostSlice[] 89 + fetchedAt: number 88 90 } 89 91 90 92 const PAGE_SIZE = 30 ··· 98 100 const feedTuners = useFeedTuners(feedDesc) 99 101 const moderationOpts = useModerationOpts() 100 102 const enabled = opts?.enabled !== false && Boolean(moderationOpts) 101 - const lastRun = React.useRef<{ 103 + const lastRun = useRef<{ 102 104 data: InfiniteData<FeedPageUnselected> 103 105 args: typeof selectArgs 104 106 result: InfiniteData<FeedPage> 105 107 } | null>(null) 108 + const lastPageCountRef = useRef(0) 106 109 107 110 // Make sure this doesn't invalidate unless really needed. 108 111 const selectArgs = React.useMemo( ··· 152 155 api, 153 156 cursor: res.cursor, 154 157 feed: res.feed, 158 + fetchedAt: Date.now(), 155 159 } 156 160 }, 157 161 initialPageParam: undefined, ··· 214 218 api: page.api, 215 219 tuner, 216 220 cursor: page.cursor, 221 + fetchedAt: page.fetchedAt, 217 222 slices: tuner 218 223 .tune(page.feed) 219 224 .map(slice => { ··· 279 284 280 285 useEffect(() => { 281 286 const {isFetching, hasNextPage, data} = query 287 + if (isFetching || !hasNextPage) { 288 + return 289 + } 282 290 291 + // avoid double-fires of fetchNextPage() 292 + if ( 293 + lastPageCountRef.current !== 0 && 294 + lastPageCountRef.current === data?.pages?.length 295 + ) { 296 + return 297 + } 298 + 299 + // fetch next page if we haven't gotten a full page of content 283 300 let count = 0 284 - let numEmpties = 0 285 301 for (const page of data?.pages || []) { 286 - if (page.slices.length === 0) { 287 - numEmpties++ 288 - } 289 302 for (const slice of page.slices) { 290 303 count += slice.items.length 291 304 } 292 305 } 293 - 294 - if ( 295 - !isFetching && 296 - hasNextPage && 297 - count < PAGE_SIZE && 298 - numEmpties < 3 && 299 - (data?.pages.length || 0) < 6 300 - ) { 306 + if (count < PAGE_SIZE && (data?.pages.length || 0) < 6) { 301 307 query.fetchNextPage() 308 + lastPageCountRef.current = data?.pages?.length || 0 302 309 } 303 310 }, [query]) 304 311
+1 -3
src/state/queries/profile.ts
··· 35 35 // if you remove it, the UI infinite-loops 36 36 // -prf 37 37 staleTime: isCurrentAccount ? STALE.SECONDS.THIRTY : STALE.MINUTES.FIVE, 38 - refetchInterval: isCurrentAccount 39 - ? STALE.SECONDS.THIRTY 40 - : STALE.MINUTES.FIVE, 38 + refetchInterval: STALE.MINUTES.FIVE, 41 39 queryKey: RQKEY(did || ''), 42 40 queryFn: async () => { 43 41 const res = await getAgent().getProfile({actor: did || ''})
+1 -1
src/view/com/feeds/FeedPage.tsx
··· 29 29 import {TabState, getTabState, getRootNavigation} from '#/lib/routes/helpers' 30 30 import {isNative} from '#/platform/detection' 31 31 32 - const POLL_FREQ = 30e3 // 30sec 32 + const POLL_FREQ = 60e3 // 60sec 33 33 34 34 export function FeedPage({ 35 35 testID,
+22 -4
src/view/com/posts/Feed.tsx
··· 29 29 import {isWeb} from '#/platform/detection' 30 30 import {listenPostCreated} from '#/state/events' 31 31 import {useSession} from '#/state/session' 32 + import {STALE} from '#/state/queries' 32 33 33 34 const LOADING_ITEM = {_reactKey: '__loading__'} 34 35 const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} 35 36 const ERROR_ITEM = {_reactKey: '__error__'} 36 37 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} 38 + 39 + const REFRESH_AFTER = STALE.HOURS.ONE 40 + const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY 37 41 38 42 let Feed = ({ 39 43 feed, ··· 77 81 const {currentAccount} = useSession() 78 82 const [isPTRing, setIsPTRing] = React.useState(false) 79 83 const checkForNewRef = React.useRef<(() => void) | null>(null) 84 + const lastFetchRef = React.useRef<number>(Date.now()) 80 85 81 86 const opts = React.useMemo( 82 87 () => ({enabled, ignoreFilterFor}), ··· 94 99 fetchNextPage, 95 100 } = usePostFeedQuery(feed, feedParams, opts) 96 101 const isEmpty = !isFetching && !data?.pages[0]?.slices.length 102 + if (data?.pages[0]) { 103 + lastFetchRef.current = data?.pages[0].fetchedAt 104 + } 97 105 98 106 const checkForNew = React.useCallback(async () => { 99 107 if (!data?.pages[0] || isFetching || !onHasNew || !enabled) { ··· 133 141 checkForNewRef.current = checkForNew 134 142 }, [checkForNew]) 135 143 React.useEffect(() => { 136 - if (enabled && checkForNewRef.current) { 137 - // check for new on enable (aka on focus) 138 - checkForNewRef.current() 144 + if (enabled) { 145 + const timeSinceFirstLoad = Date.now() - lastFetchRef.current 146 + if (timeSinceFirstLoad > REFRESH_AFTER) { 147 + // do a full refresh 148 + scrollElRef?.current?.scrollToOffset({offset: 0, animated: false}) 149 + queryClient.resetQueries({queryKey: RQKEY(feed)}) 150 + } else if ( 151 + timeSinceFirstLoad > CHECK_LATEST_AFTER && 152 + checkForNewRef.current 153 + ) { 154 + // check for new on enable (aka on focus) 155 + checkForNewRef.current() 156 + } 139 157 } 140 - }, [enabled]) 158 + }, [enabled, feed, queryClient, scrollElRef]) 141 159 React.useEffect(() => { 142 160 let cleanup1: () => void | undefined, cleanup2: () => void | undefined 143 161 const subscription = AppState.addEventListener('change', nextAppState => {
+17 -67
src/view/screens/ProfileFeed.tsx
··· 1 1 import React, {useMemo, useCallback} from 'react' 2 2 import {Dimensions, StyleSheet, View, ActivityIndicator} from 'react-native' 3 3 import {NativeStackScreenProps} from '@react-navigation/native-stack' 4 - import {useNavigation} from '@react-navigation/native' 4 + import {useIsFocused, useNavigation} from '@react-navigation/native' 5 5 import {useQueryClient} from '@tanstack/react-query' 6 6 import {usePalette} from 'lib/hooks/usePalette' 7 7 import {HeartIcon, HeartIconSolid} from 'lib/icons' ··· 42 42 import {Trans, msg} from '@lingui/macro' 43 43 import {useLingui} from '@lingui/react' 44 44 import {useModalControls} from '#/state/modals' 45 - import { 46 - useFeedSourceInfoQuery, 47 - FeedSourceFeedInfo, 48 - useIsFeedPublicQuery, 49 - } from '#/state/queries/feed' 45 + import {useFeedSourceInfoQuery, FeedSourceFeedInfo} from '#/state/queries/feed' 50 46 import {useResolveUriQuery} from '#/state/queries/resolve-uri' 51 47 import { 52 48 UsePreferencesQueryResponse, ··· 132 128 function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { 133 129 const {data: preferences} = usePreferencesQuery() 134 130 const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) 135 - const {isLoading: isPublicStatusLoading, data: isPublicResponse} = 136 - useIsFeedPublicQuery({uri: feedUri}) 137 131 138 - if (!preferences || !info || isPublicStatusLoading) { 132 + if (!preferences || !info) { 139 133 return ( 140 134 <CenteredView> 141 135 <View style={s.p20}> ··· 149 143 <ProfileFeedScreenInner 150 144 preferences={preferences} 151 145 feedInfo={info as FeedSourceFeedInfo} 152 - isPublicResponse={isPublicResponse} 153 146 /> 154 147 ) 155 148 } ··· 157 150 export function ProfileFeedScreenInner({ 158 151 preferences, 159 152 feedInfo, 160 - isPublicResponse, 161 153 }: { 162 154 preferences: UsePreferencesQueryResponse 163 155 feedInfo: FeedSourceFeedInfo 164 - isPublicResponse: ReturnType<typeof useIsFeedPublicQuery>['data'] 165 156 }) { 166 157 const {_} = useLingui() 167 158 const pal = usePalette('default') ··· 170 161 const {openComposer} = useComposerControls() 171 162 const {track} = useAnalytics() 172 163 const feedSectionRef = React.useRef<SectionRef>(null) 164 + const isScreenFocused = useIsFocused() 173 165 174 166 const { 175 167 mutateAsync: saveFeed, ··· 204 196 (!!pinnedFeed || preferences.feeds.pinned.includes(feedInfo.uri)) 205 197 206 198 useSetTitle(feedInfo?.displayName) 199 + 200 + // event handlers 201 + // 207 202 208 203 const onToggleSaved = React.useCallback(async () => { 209 204 try { ··· 398 393 isHeaderReady={true} 399 394 renderHeader={renderHeader} 400 395 onCurrentPageSelected={onCurrentPageSelected}> 401 - {({headerHeight, scrollElRef, isFocused}) => 402 - isPublicResponse?.isPublic ? ( 403 - <FeedSection 404 - ref={feedSectionRef} 405 - feed={`feedgen|${feedInfo.uri}`} 406 - headerHeight={headerHeight} 407 - scrollElRef={scrollElRef as ListRef} 408 - isFocused={isFocused} 409 - /> 410 - ) : ( 411 - <CenteredView sideBorders style={[{paddingTop: headerHeight}]}> 412 - <NonPublicFeedMessage rawError={isPublicResponse?.error} /> 413 - </CenteredView> 414 - ) 415 - } 396 + {({headerHeight, scrollElRef, isFocused}) => ( 397 + <FeedSection 398 + ref={feedSectionRef} 399 + feed={`feedgen|${feedInfo.uri}`} 400 + headerHeight={headerHeight} 401 + scrollElRef={scrollElRef as ListRef} 402 + isFocused={isScreenFocused && isFocused} 403 + /> 404 + )} 416 405 {({headerHeight, scrollElRef}) => ( 417 406 <AboutSection 418 407 feedOwnerDid={feedInfo.creatorDid} ··· 446 435 ) 447 436 } 448 437 449 - function NonPublicFeedMessage({rawError}: {rawError?: Error}) { 450 - const pal = usePalette('default') 451 - 452 - return ( 453 - <View 454 - style={[ 455 - pal.border, 456 - { 457 - padding: 18, 458 - borderTopWidth: 1, 459 - minHeight: Dimensions.get('window').height * 1.5, 460 - }, 461 - ]}> 462 - <View 463 - style={[ 464 - pal.viewLight, 465 - { 466 - padding: 12, 467 - borderRadius: 8, 468 - gap: 12, 469 - }, 470 - ]}> 471 - <Text style={[pal.text]}> 472 - <Trans> 473 - Looks like this feed is only available to users with a Bluesky 474 - account. Please sign up or sign in to view this feed! 475 - </Trans> 476 - </Text> 477 - 478 - {rawError?.message && ( 479 - <Text style={pal.textLight}> 480 - <Trans>Message from server</Trans>: {rawError.message} 481 - </Text> 482 - )} 483 - </View> 484 - </View> 485 - ) 486 - } 487 - 488 438 interface FeedSectionProps { 489 439 feed: FeedDescriptor 490 440 headerHeight: number ··· 519 469 <Feed 520 470 enabled={isFocused} 521 471 feed={feed} 522 - pollInterval={30e3} 472 + pollInterval={60e3} 523 473 scrollElRef={scrollElRef} 524 474 onHasNew={setHasNew} 525 475 onScrolledDownChange={setIsScrolledDown}
+4 -3
src/view/screens/ProfileList.tsx
··· 1 1 import React, {useCallback, useMemo} from 'react' 2 2 import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native' 3 - import {useFocusEffect} from '@react-navigation/native' 3 + import {useFocusEffect, useIsFocused} from '@react-navigation/native' 4 4 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' 5 5 import {useNavigation} from '@react-navigation/native' 6 6 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' ··· 115 115 const aboutSectionRef = React.useRef<SectionRef>(null) 116 116 const {openModal} = useModalControls() 117 117 const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' 118 + const isScreenFocused = useIsFocused() 118 119 119 120 useSetTitle(list.name) 120 121 ··· 165 166 feed={`list|${uri}`} 166 167 scrollElRef={scrollElRef as ListRef} 167 168 headerHeight={headerHeight} 168 - isFocused={isFocused} 169 + isFocused={isScreenFocused && isFocused} 169 170 /> 170 171 )} 171 172 {({headerHeight, scrollElRef}) => ( ··· 623 624 testID="listFeed" 624 625 enabled={isFocused} 625 626 feed={feed} 626 - pollInterval={30e3} 627 + pollInterval={60e3} 627 628 scrollElRef={scrollElRef} 628 629 onHasNew={setHasNew} 629 630 onScrolledDownChange={setIsScrolledDown}