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 801 height={size || 24} 802 802 style={style}> 803 803 <Line 804 - stroke-linecap="round" 805 - stroke-linejoin="round" 804 + strokeLinecap="round" 805 + strokeLinejoin="round" 806 806 x1="12" 807 807 y1="5.5" 808 808 x2="12" ··· 810 810 strokeWidth={strokeWidth * 1.5} 811 811 /> 812 812 <Line 813 - stroke-linecap="round" 814 - stroke-linejoin="round" 813 + strokeLinecap="round" 814 + strokeLinejoin="round" 815 815 x1="5.5" 816 816 y1="12" 817 817 x2="18.5" ··· 943 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 944 <Path 945 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" 946 + strokeLinecap="round" 947 947 /> 948 948 <Path 949 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" 950 + strokeLinecap="round" 951 951 /> 952 952 <Path 953 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 20 return ( 21 21 isTab(currentRoute.name, 'Home') || 22 22 isTab(currentRoute.name, 'Search') || 23 + isTab(currentRoute.name, 'Feeds') || 23 24 isTab(currentRoute.name, 'Notifications') || 24 25 isTab(currentRoute.name, 'MyProfile') 25 26 )
+10 -1
src/state/models/feeds/multi-feed.ts
··· 6 6 import {PostsFeedModel} from './posts' 7 7 import {PostsFeedSliceModel} from './post' 8 8 9 - const FEED_PAGE_SIZE = 5 9 + const FEED_PAGE_SIZE = 10 10 10 const FEEDS_PAGE_SIZE = 3 11 11 12 12 export type MultiFeedItem = ··· 145 145 async refresh() { 146 146 this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds 147 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 + } 148 157 } 149 158 150 159 /**
+12 -37
src/state/models/feeds/posts.ts
··· 6 6 } from '@atproto/api' 7 7 import AwaitLock from 'await-lock' 8 8 import {bundleAsync} from 'lib/async/bundle' 9 - import sampleSize from 'lodash.samplesize' 10 9 import {RootStoreModel} from '../root-store' 11 10 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 11 import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' 19 12 import {PostsFeedSliceModel} from './post' 20 13 ··· 49 42 50 43 constructor( 51 44 public rootStore: RootStoreModel, 52 - public feedType: 'home' | 'author' | 'suggested' | 'custom', 45 + public feedType: 'home' | 'author' | 'custom', 53 46 params: 54 47 | GetTimeline.QueryParams 55 48 | GetAuthorFeed.QueryParams ··· 121 114 this.tuner.reset() 122 115 } 123 116 124 - switchFeedType(feedType: 'home' | 'suggested') { 125 - if (this.feedType === feedType) { 126 - return 127 - } 128 - this.feedType = feedType 129 - return this.setup() 130 - } 131 - 132 117 get feedTuners() { 133 118 if (this.feedType === 'custom') { 134 119 return [ ··· 263 248 * Check if new posts are available 264 249 */ 265 250 async checkForLatest() { 266 - if (this.hasNewLatest || this.feedType === 'suggested') { 251 + if (this.hasNewLatest) { 267 252 return 268 253 } 269 254 const res = await this._getFeed({limit: this.pageSize}) ··· 415 400 GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response 416 401 > { 417 402 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') { 403 + if (this.feedType === 'home') { 437 404 return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) 438 405 } else if (this.feedType === 'custom') { 439 - return this.rootStore.agent.app.bsky.feed.getFeed( 406 + const res = await this.rootStore.agent.app.bsky.feed.getFeed( 440 407 params as GetCustomFeed.QueryParams, 441 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 442 417 } else { 443 418 return this.rootStore.agent.getAuthorFeed( 444 419 params as GetAuthorFeed.QueryParams,
+19 -4
src/view/screens/Feeds.tsx
··· 14 14 import {MultiFeed} from 'view/com/posts/MultiFeed' 15 15 import {isDesktopWeb} from 'platform/detection' 16 16 import {usePalette} from 'lib/hooks/usePalette' 17 + import {useTimer} from 'lib/hooks/useTimer' 17 18 import {useStores} from 'state/index' 18 19 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' 19 20 import {ComposeIcon2, CogIcon} from 'lib/icons' 20 21 import {s} from 'lib/styles' 21 22 23 + const LOAD_NEW_PROMPT_TIME = 60e3 // 60 seconds 22 24 const HEADER_OFFSET = isDesktopWeb ? 0 : 40 23 25 24 26 type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> ··· 33 35 ) 34 36 const [onMainScroll, isScrolledDown, resetMainScroll] = 35 37 useOnMainScroll(store) 38 + const [loadPromptVisible, setLoadPromptVisible] = React.useState(false) 39 + const [resetPromptTimer] = useTimer(LOAD_NEW_PROMPT_TIME, () => { 40 + setLoadPromptVisible(true) 41 + }) 36 42 37 43 const onSoftReset = React.useCallback(() => { 38 44 flatListRef.current?.scrollToOffset({offset: 0}) 45 + multifeed.loadLatest() 46 + resetPromptTimer() 47 + setLoadPromptVisible(false) 39 48 resetMainScroll() 40 - }, [flatListRef, resetMainScroll]) 49 + }, [ 50 + flatListRef, 51 + resetMainScroll, 52 + multifeed, 53 + resetPromptTimer, 54 + setLoadPromptVisible, 55 + ]) 41 56 42 57 useFocusEffect( 43 58 React.useCallback(() => { ··· 99 114 hideOnScroll 100 115 renderButton={renderHeaderBtn} 101 116 /> 102 - {isScrolledDown ? ( 117 + {isScrolledDown || loadPromptVisible ? ( 103 118 <LoadLatestBtn 104 119 onPress={onSoftReset} 105 - label="Scroll to top" 106 - showIndicator={false} 120 + label="Load latest posts" 121 + showIndicator={loadPromptVisible} 107 122 /> 108 123 ) : null} 109 124 <FAB
+8 -1
src/view/screens/Home.tsx
··· 52 52 model.setup() 53 53 feeds.push(model) 54 54 } 55 + pagerRef.current?.setPage(0) 55 56 setCustomFeeds(feeds) 56 - }, [store, store.me.savedFeeds.pinned, customFeeds, setCustomFeeds]) 57 + }, [ 58 + store, 59 + store.me.savedFeeds.pinned, 60 + customFeeds, 61 + setCustomFeeds, 62 + pagerRef, 63 + ]) 57 64 58 65 useFocusEffect( 59 66 React.useCallback(() => {