Bluesky app fork with some witchin' additions 💫

More custom-feed behavior fixes [APP-678] (#831)

* Remove extraneous custom-feed health check

* Fixes to custom feed preference sync

* Fix lint

* Remove dead code (client-side suggested posts constructor)

* Enforce the feed-fetch limit in the client if the generator fails to observe the parameter

* Bump the number of items fetched in the multifeed per feed from 5 to 10

* Reset the currently active feed when the pinned feeds change

* Some fixes to icons

* Add a prompt to load latest to the multifeed

* Remove debug

authored by

Paul Frazee and committed by
GitHub
3217c7ff e9c84a19

+88 -186
-137
src/lib/api/build-suggested-posts.ts
··· 1 - import {RootStoreModel} from 'state/index' 2 - import { 3 - AppBskyFeedDefs, 4 - AppBskyFeedGetAuthorFeed as GetAuthorFeed, 5 - } from '@atproto/api' 6 - type ReasonRepost = AppBskyFeedDefs.ReasonRepost 7 - 8 - async function getMultipleAuthorsPosts( 9 - rootStore: RootStoreModel, 10 - authors: string[], 11 - cursor: string | undefined = undefined, 12 - limit: number = 10, 13 - ) { 14 - const responses = await Promise.all( 15 - authors.map((actor, index) => 16 - rootStore.agent 17 - .getAuthorFeed({ 18 - actor, 19 - limit, 20 - cursor: cursor ? cursor.split(',')[index] : undefined, 21 - }) 22 - .catch(_err => ({success: false, headers: {}, data: {feed: []}})), 23 - ), 24 - ) 25 - return responses 26 - } 27 - 28 - function mergePosts( 29 - responses: GetAuthorFeed.Response[], 30 - {repostsOnly, bestOfOnly}: {repostsOnly?: boolean; bestOfOnly?: boolean}, 31 - ) { 32 - let posts: AppBskyFeedDefs.FeedViewPost[] = [] 33 - 34 - if (bestOfOnly) { 35 - for (const res of responses) { 36 - if (res.success) { 37 - // filter the feed down to the post with the most likes 38 - res.data.feed = res.data.feed.reduce( 39 - (acc: AppBskyFeedDefs.FeedViewPost[], v) => { 40 - if ( 41 - !acc?.[0] && 42 - !v.reason && 43 - !v.reply && 44 - isRecentEnough(v.post.indexedAt) 45 - ) { 46 - return [v] 47 - } 48 - if ( 49 - acc && 50 - !v.reason && 51 - !v.reply && 52 - (v.post.likeCount || 0) > (acc[0]?.post.likeCount || 0) && 53 - isRecentEnough(v.post.indexedAt) 54 - ) { 55 - return [v] 56 - } 57 - return acc 58 - }, 59 - [], 60 - ) 61 - } 62 - } 63 - } 64 - 65 - // merge into one array 66 - for (const res of responses) { 67 - if (res.success) { 68 - posts = posts.concat(res.data.feed) 69 - } 70 - } 71 - 72 - // filter down to reposts of other users 73 - const uris = new Set() 74 - posts = posts.filter(p => { 75 - if (repostsOnly && !isARepostOfSomeoneElse(p)) { 76 - return false 77 - } 78 - if (uris.has(p.post.uri)) { 79 - return false 80 - } 81 - uris.add(p.post.uri) 82 - return true 83 - }) 84 - 85 - // sort by index time 86 - posts.sort((a, b) => { 87 - return ( 88 - Number(new Date(b.post.indexedAt)) - Number(new Date(a.post.indexedAt)) 89 - ) 90 - }) 91 - 92 - return posts 93 - } 94 - 95 - function isARepostOfSomeoneElse(post: AppBskyFeedDefs.FeedViewPost): boolean { 96 - return ( 97 - post.reason?.$type === 'app.bsky.feed.defs#reasonRepost' && 98 - post.post.author.did !== (post.reason as ReasonRepost).by.did 99 - ) 100 - } 101 - 102 - function getCombinedCursors(responses: GetAuthorFeed.Response[]) { 103 - let hasCursor = false 104 - const cursors = responses.map(r => { 105 - if (r.data.cursor) { 106 - hasCursor = true 107 - return r.data.cursor 108 - } 109 - return '' 110 - }) 111 - if (!hasCursor) { 112 - return undefined 113 - } 114 - const combinedCursors = cursors.join(',') 115 - return combinedCursors 116 - } 117 - 118 - function isCombinedCursor(cursor: string) { 119 - return cursor.includes(',') 120 - } 121 - 122 - const TWO_DAYS_AGO = Date.now() - 1e3 * 60 * 60 * 48 123 - function isRecentEnough(date: string) { 124 - try { 125 - const d = Number(new Date(date)) 126 - return d > TWO_DAYS_AGO 127 - } catch { 128 - return false 129 - } 130 - } 131 - 132 - export { 133 - getMultipleAuthorsPosts, 134 - mergePosts, 135 - getCombinedCursors, 136 - isCombinedCursor, 137 - }
···
+32
src/lib/hooks/useTimer.ts
···
··· 1 + import * as React from 'react' 2 + 3 + /** 4 + * Helper hook to run persistent timers on views 5 + */ 6 + export function useTimer(time: number, handler: () => void) { 7 + const timer = React.useRef(undefined) 8 + 9 + // function to restart the timer 10 + const reset = React.useCallback(() => { 11 + if (timer.current) { 12 + clearTimeout(timer.current) 13 + } 14 + timer.current = setTimeout(handler, time) 15 + }, [time, timer, handler]) 16 + 17 + // function to cancel the timer 18 + const cancel = React.useCallback(() => { 19 + if (timer.current) { 20 + clearTimeout(timer.current) 21 + timer.current = undefined 22 + } 23 + }, [timer]) 24 + 25 + // start the timer immediately 26 + React.useEffect(() => { 27 + reset() 28 + // eslint-disable-next-line react-hooks/exhaustive-deps 29 + }, []) 30 + 31 + return [reset, cancel] 32 + }
+6 -6
src/lib/icons.tsx
··· 801 height={size || 24} 802 style={style}> 803 <Line 804 - stroke-linecap="round" 805 - stroke-linejoin="round" 806 x1="12" 807 y1="5.5" 808 x2="12" ··· 810 strokeWidth={strokeWidth * 1.5} 811 /> 812 <Line 813 - stroke-linecap="round" 814 - stroke-linejoin="round" 815 x1="5.5" 816 y1="12" 817 x2="18.5" ··· 943 <Path d="M5.25593 8.3303L5.25609 8.33047L5.25616 8.33056L5.25621 8.33061L5.27377 8.35018L5.29289 8.3693L13.7929 16.8693L13.8131 16.8895L13.8338 16.908L13.834 16.9081L13.8342 16.9083L13.8342 16.9083L13.8345 16.9086L13.8381 16.9118L13.8574 16.9294C13.8752 16.9458 13.9026 16.9711 13.9377 17.0043C14.0081 17.0708 14.1088 17.1683 14.2258 17.2881C14.4635 17.5315 14.7526 17.8509 14.9928 18.1812C15.2067 18.4755 15.3299 18.7087 15.3817 18.8634C14.0859 19.5872 12.5926 20 11 20C6.02944 20 2 15.9706 2 11C2 9.4151 2.40883 7.9285 3.12619 6.63699C3.304 6.69748 3.56745 6.84213 3.89275 7.08309C4.24679 7.34534 4.58866 7.65673 4.84827 7.9106C4.97633 8.03583 5.08062 8.14337 5.152 8.21863C5.18763 8.25619 5.21487 8.28551 5.23257 8.30473L5.25178 8.32572L5.25571 8.33006L5.25593 8.3303ZM3.00217 6.60712C3.00217 6.6071 3.00267 6.6071 3.00372 6.60715C3.00271 6.60716 3.00218 6.60714 3.00217 6.60712Z" /> 944 <Path 945 d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14" 946 - stroke-linecap="round" 947 /> 948 <Path 949 d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13" 950 - stroke-linecap="round" 951 /> 952 <Path 953 d="M12 12C12 12.7403 11.5978 13.3866 11 13.7324L8.26756 11C8.61337 10.4022 9.25972 10 10 10C11.1046 10 12 10.8954 12 12Z"
··· 801 height={size || 24} 802 style={style}> 803 <Line 804 + strokeLinecap="round" 805 + strokeLinejoin="round" 806 x1="12" 807 y1="5.5" 808 x2="12" ··· 810 strokeWidth={strokeWidth * 1.5} 811 /> 812 <Line 813 + strokeLinecap="round" 814 + strokeLinejoin="round" 815 x1="5.5" 816 y1="12" 817 x2="18.5" ··· 943 <Path d="M5.25593 8.3303L5.25609 8.33047L5.25616 8.33056L5.25621 8.33061L5.27377 8.35018L5.29289 8.3693L13.7929 16.8693L13.8131 16.8895L13.8338 16.908L13.834 16.9081L13.8342 16.9083L13.8342 16.9083L13.8345 16.9086L13.8381 16.9118L13.8574 16.9294C13.8752 16.9458 13.9026 16.9711 13.9377 17.0043C14.0081 17.0708 14.1088 17.1683 14.2258 17.2881C14.4635 17.5315 14.7526 17.8509 14.9928 18.1812C15.2067 18.4755 15.3299 18.7087 15.3817 18.8634C14.0859 19.5872 12.5926 20 11 20C6.02944 20 2 15.9706 2 11C2 9.4151 2.40883 7.9285 3.12619 6.63699C3.304 6.69748 3.56745 6.84213 3.89275 7.08309C4.24679 7.34534 4.58866 7.65673 4.84827 7.9106C4.97633 8.03583 5.08062 8.14337 5.152 8.21863C5.18763 8.25619 5.21487 8.28551 5.23257 8.30473L5.25178 8.32572L5.25571 8.33006L5.25593 8.3303ZM3.00217 6.60712C3.00217 6.6071 3.00267 6.6071 3.00372 6.60715C3.00271 6.60716 3.00218 6.60714 3.00217 6.60712Z" /> 944 <Path 945 d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14" 946 + strokeLinecap="round" 947 /> 948 <Path 949 d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13" 950 + strokeLinecap="round" 951 /> 952 <Path 953 d="M12 12C12 12.7403 11.5978 13.3866 11 13.7324L8.26756 11C8.61337 10.4022 9.25972 10 10 10C11.1046 10 12 10.8954 12 12Z"
+1
src/lib/routes/helpers.ts
··· 20 return ( 21 isTab(currentRoute.name, 'Home') || 22 isTab(currentRoute.name, 'Search') || 23 isTab(currentRoute.name, 'Notifications') || 24 isTab(currentRoute.name, 'MyProfile') 25 )
··· 20 return ( 21 isTab(currentRoute.name, 'Home') || 22 isTab(currentRoute.name, 'Search') || 23 + isTab(currentRoute.name, 'Feeds') || 24 isTab(currentRoute.name, 'Notifications') || 25 isTab(currentRoute.name, 'MyProfile') 26 )
+10 -1
src/state/models/feeds/multi-feed.ts
··· 6 import {PostsFeedModel} from './posts' 7 import {PostsFeedSliceModel} from './post' 8 9 - const FEED_PAGE_SIZE = 5 10 const FEEDS_PAGE_SIZE = 3 11 12 export type MultiFeedItem = ··· 145 async refresh() { 146 this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds 147 await this.loadMore(true) 148 } 149 150 /**
··· 6 import {PostsFeedModel} from './posts' 7 import {PostsFeedSliceModel} from './post' 8 9 + const FEED_PAGE_SIZE = 10 10 const FEEDS_PAGE_SIZE = 3 11 12 export type MultiFeedItem = ··· 145 async refresh() { 146 this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds 147 await this.loadMore(true) 148 + } 149 + 150 + /** 151 + * Load latest in the active feeds 152 + */ 153 + loadLatest() { 154 + for (const feed of this.feeds) { 155 + /* dont await */ feed.refresh() 156 + } 157 } 158 159 /**
+12 -37
src/state/models/feeds/posts.ts
··· 6 } from '@atproto/api' 7 import AwaitLock from 'await-lock' 8 import {bundleAsync} from 'lib/async/bundle' 9 - import sampleSize from 'lodash.samplesize' 10 import {RootStoreModel} from '../root-store' 11 import {cleanError} from 'lib/strings/errors' 12 - import {SUGGESTED_FOLLOWS} from 'lib/constants' 13 - import { 14 - getCombinedCursors, 15 - getMultipleAuthorsPosts, 16 - mergePosts, 17 - } from 'lib/api/build-suggested-posts' 18 import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' 19 import {PostsFeedSliceModel} from './post' 20 ··· 49 50 constructor( 51 public rootStore: RootStoreModel, 52 - public feedType: 'home' | 'author' | 'suggested' | 'custom', 53 params: 54 | GetTimeline.QueryParams 55 | GetAuthorFeed.QueryParams ··· 121 this.tuner.reset() 122 } 123 124 - switchFeedType(feedType: 'home' | 'suggested') { 125 - if (this.feedType === feedType) { 126 - return 127 - } 128 - this.feedType = feedType 129 - return this.setup() 130 - } 131 - 132 get feedTuners() { 133 if (this.feedType === 'custom') { 134 return [ ··· 263 * Check if new posts are available 264 */ 265 async checkForLatest() { 266 - if (this.hasNewLatest || this.feedType === 'suggested') { 267 return 268 } 269 const res = await this._getFeed({limit: this.pageSize}) ··· 415 GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response 416 > { 417 params = Object.assign({}, this.params, params) 418 - if (this.feedType === 'suggested') { 419 - const responses = await getMultipleAuthorsPosts( 420 - this.rootStore, 421 - sampleSize(SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), 20), 422 - params.cursor, 423 - 20, 424 - ) 425 - const combinedCursor = getCombinedCursors(responses) 426 - const finalData = mergePosts(responses, {bestOfOnly: true}) 427 - const lastHeaders = responses[responses.length - 1].headers 428 - return { 429 - success: true, 430 - data: { 431 - feed: finalData, 432 - cursor: combinedCursor, 433 - }, 434 - headers: lastHeaders, 435 - } 436 - } else if (this.feedType === 'home') { 437 return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) 438 } else if (this.feedType === 'custom') { 439 - return this.rootStore.agent.app.bsky.feed.getFeed( 440 params as GetCustomFeed.QueryParams, 441 ) 442 } else { 443 return this.rootStore.agent.getAuthorFeed( 444 params as GetAuthorFeed.QueryParams,
··· 6 } from '@atproto/api' 7 import AwaitLock from 'await-lock' 8 import {bundleAsync} from 'lib/async/bundle' 9 import {RootStoreModel} from '../root-store' 10 import {cleanError} from 'lib/strings/errors' 11 import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' 12 import {PostsFeedSliceModel} from './post' 13 ··· 42 43 constructor( 44 public rootStore: RootStoreModel, 45 + public feedType: 'home' | 'author' | 'custom', 46 params: 47 | GetTimeline.QueryParams 48 | GetAuthorFeed.QueryParams ··· 114 this.tuner.reset() 115 } 116 117 get feedTuners() { 118 if (this.feedType === 'custom') { 119 return [ ··· 248 * Check if new posts are available 249 */ 250 async checkForLatest() { 251 + if (this.hasNewLatest) { 252 return 253 } 254 const res = await this._getFeed({limit: this.pageSize}) ··· 400 GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response 401 > { 402 params = Object.assign({}, this.params, params) 403 + if (this.feedType === 'home') { 404 return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) 405 } else if (this.feedType === 'custom') { 406 + const res = await this.rootStore.agent.app.bsky.feed.getFeed( 407 params as GetCustomFeed.QueryParams, 408 ) 409 + // NOTE 410 + // some custom feeds fail to enforce the pagination limit 411 + // so we manually truncate here 412 + // -prf 413 + if (params.limit && res.data.feed.length > params.limit) { 414 + res.data.feed = res.data.feed.slice(0, params.limit) 415 + } 416 + return res 417 } else { 418 return this.rootStore.agent.getAuthorFeed( 419 params as GetAuthorFeed.QueryParams,
+19 -4
src/view/screens/Feeds.tsx
··· 14 import {MultiFeed} from 'view/com/posts/MultiFeed' 15 import {isDesktopWeb} from 'platform/detection' 16 import {usePalette} from 'lib/hooks/usePalette' 17 import {useStores} from 'state/index' 18 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' 19 import {ComposeIcon2, CogIcon} from 'lib/icons' 20 import {s} from 'lib/styles' 21 22 const HEADER_OFFSET = isDesktopWeb ? 0 : 40 23 24 type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> ··· 33 ) 34 const [onMainScroll, isScrolledDown, resetMainScroll] = 35 useOnMainScroll(store) 36 37 const onSoftReset = React.useCallback(() => { 38 flatListRef.current?.scrollToOffset({offset: 0}) 39 resetMainScroll() 40 - }, [flatListRef, resetMainScroll]) 41 42 useFocusEffect( 43 React.useCallback(() => { ··· 99 hideOnScroll 100 renderButton={renderHeaderBtn} 101 /> 102 - {isScrolledDown ? ( 103 <LoadLatestBtn 104 onPress={onSoftReset} 105 - label="Scroll to top" 106 - showIndicator={false} 107 /> 108 ) : null} 109 <FAB
··· 14 import {MultiFeed} from 'view/com/posts/MultiFeed' 15 import {isDesktopWeb} from 'platform/detection' 16 import {usePalette} from 'lib/hooks/usePalette' 17 + import {useTimer} from 'lib/hooks/useTimer' 18 import {useStores} from 'state/index' 19 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' 20 import {ComposeIcon2, CogIcon} from 'lib/icons' 21 import {s} from 'lib/styles' 22 23 + const LOAD_NEW_PROMPT_TIME = 60e3 // 60 seconds 24 const HEADER_OFFSET = isDesktopWeb ? 0 : 40 25 26 type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> ··· 35 ) 36 const [onMainScroll, isScrolledDown, resetMainScroll] = 37 useOnMainScroll(store) 38 + const [loadPromptVisible, setLoadPromptVisible] = React.useState(false) 39 + const [resetPromptTimer] = useTimer(LOAD_NEW_PROMPT_TIME, () => { 40 + setLoadPromptVisible(true) 41 + }) 42 43 const onSoftReset = React.useCallback(() => { 44 flatListRef.current?.scrollToOffset({offset: 0}) 45 + multifeed.loadLatest() 46 + resetPromptTimer() 47 + setLoadPromptVisible(false) 48 resetMainScroll() 49 + }, [ 50 + flatListRef, 51 + resetMainScroll, 52 + multifeed, 53 + resetPromptTimer, 54 + setLoadPromptVisible, 55 + ]) 56 57 useFocusEffect( 58 React.useCallback(() => { ··· 114 hideOnScroll 115 renderButton={renderHeaderBtn} 116 /> 117 + {isScrolledDown || loadPromptVisible ? ( 118 <LoadLatestBtn 119 onPress={onSoftReset} 120 + label="Load latest posts" 121 + showIndicator={loadPromptVisible} 122 /> 123 ) : null} 124 <FAB
+8 -1
src/view/screens/Home.tsx
··· 52 model.setup() 53 feeds.push(model) 54 } 55 setCustomFeeds(feeds) 56 - }, [store, store.me.savedFeeds.pinned, customFeeds, setCustomFeeds]) 57 58 useFocusEffect( 59 React.useCallback(() => {
··· 52 model.setup() 53 feeds.push(model) 54 } 55 + pagerRef.current?.setPage(0) 56 setCustomFeeds(feeds) 57 + }, [ 58 + store, 59 + store.me.savedFeeds.pinned, 60 + customFeeds, 61 + setCustomFeeds, 62 + pagerRef, 63 + ]) 64 65 useFocusEffect( 66 React.useCallback(() => {