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
zio/stable
Paul Frazee 2023-06-02 09:48:53 -05:00 committed by GitHub
parent e9c84a192b
commit 3217c7ff32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 88 additions and 186 deletions

View File

@ -1,137 +0,0 @@
import {RootStoreModel} from 'state/index'
import {
AppBskyFeedDefs,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
} from '@atproto/api'
type ReasonRepost = AppBskyFeedDefs.ReasonRepost
async function getMultipleAuthorsPosts(
rootStore: RootStoreModel,
authors: string[],
cursor: string | undefined = undefined,
limit: number = 10,
) {
const responses = await Promise.all(
authors.map((actor, index) =>
rootStore.agent
.getAuthorFeed({
actor,
limit,
cursor: cursor ? cursor.split(',')[index] : undefined,
})
.catch(_err => ({success: false, headers: {}, data: {feed: []}})),
),
)
return responses
}
function mergePosts(
responses: GetAuthorFeed.Response[],
{repostsOnly, bestOfOnly}: {repostsOnly?: boolean; bestOfOnly?: boolean},
) {
let posts: AppBskyFeedDefs.FeedViewPost[] = []
if (bestOfOnly) {
for (const res of responses) {
if (res.success) {
// filter the feed down to the post with the most likes
res.data.feed = res.data.feed.reduce(
(acc: AppBskyFeedDefs.FeedViewPost[], v) => {
if (
!acc?.[0] &&
!v.reason &&
!v.reply &&
isRecentEnough(v.post.indexedAt)
) {
return [v]
}
if (
acc &&
!v.reason &&
!v.reply &&
(v.post.likeCount || 0) > (acc[0]?.post.likeCount || 0) &&
isRecentEnough(v.post.indexedAt)
) {
return [v]
}
return acc
},
[],
)
}
}
}
// merge into one array
for (const res of responses) {
if (res.success) {
posts = posts.concat(res.data.feed)
}
}
// filter down to reposts of other users
const uris = new Set()
posts = posts.filter(p => {
if (repostsOnly && !isARepostOfSomeoneElse(p)) {
return false
}
if (uris.has(p.post.uri)) {
return false
}
uris.add(p.post.uri)
return true
})
// sort by index time
posts.sort((a, b) => {
return (
Number(new Date(b.post.indexedAt)) - Number(new Date(a.post.indexedAt))
)
})
return posts
}
function isARepostOfSomeoneElse(post: AppBskyFeedDefs.FeedViewPost): boolean {
return (
post.reason?.$type === 'app.bsky.feed.defs#reasonRepost' &&
post.post.author.did !== (post.reason as ReasonRepost).by.did
)
}
function getCombinedCursors(responses: GetAuthorFeed.Response[]) {
let hasCursor = false
const cursors = responses.map(r => {
if (r.data.cursor) {
hasCursor = true
return r.data.cursor
}
return ''
})
if (!hasCursor) {
return undefined
}
const combinedCursors = cursors.join(',')
return combinedCursors
}
function isCombinedCursor(cursor: string) {
return cursor.includes(',')
}
const TWO_DAYS_AGO = Date.now() - 1e3 * 60 * 60 * 48
function isRecentEnough(date: string) {
try {
const d = Number(new Date(date))
return d > TWO_DAYS_AGO
} catch {
return false
}
}
export {
getMultipleAuthorsPosts,
mergePosts,
getCombinedCursors,
isCombinedCursor,
}

View File

@ -0,0 +1,32 @@
import * as React from 'react'
/**
* Helper hook to run persistent timers on views
*/
export function useTimer(time: number, handler: () => void) {
const timer = React.useRef(undefined)
// function to restart the timer
const reset = React.useCallback(() => {
if (timer.current) {
clearTimeout(timer.current)
}
timer.current = setTimeout(handler, time)
}, [time, timer, handler])
// function to cancel the timer
const cancel = React.useCallback(() => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = undefined
}
}, [timer])
// start the timer immediately
React.useEffect(() => {
reset()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return [reset, cancel]
}

View File

@ -801,8 +801,8 @@ export function SquarePlusIcon({
height={size || 24} height={size || 24}
style={style}> style={style}>
<Line <Line
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
x1="12" x1="12"
y1="5.5" y1="5.5"
x2="12" x2="12"
@ -810,8 +810,8 @@ export function SquarePlusIcon({
strokeWidth={strokeWidth * 1.5} strokeWidth={strokeWidth * 1.5}
/> />
<Line <Line
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
x1="5.5" x1="5.5"
y1="12" y1="12"
x2="18.5" x2="18.5"
@ -943,11 +943,11 @@ export function SatelliteDishIcon({
<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" /> <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" />
<Path <Path
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" 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"
stroke-linecap="round" strokeLinecap="round"
/> />
<Path <Path
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" 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"
stroke-linecap="round" strokeLinecap="round"
/> />
<Path <Path
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" 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"

View File

@ -20,6 +20,7 @@ export function isStateAtTabRoot(state: State | undefined) {
return ( return (
isTab(currentRoute.name, 'Home') || isTab(currentRoute.name, 'Home') ||
isTab(currentRoute.name, 'Search') || isTab(currentRoute.name, 'Search') ||
isTab(currentRoute.name, 'Feeds') ||
isTab(currentRoute.name, 'Notifications') || isTab(currentRoute.name, 'Notifications') ||
isTab(currentRoute.name, 'MyProfile') isTab(currentRoute.name, 'MyProfile')
) )

View File

@ -6,7 +6,7 @@ import {CustomFeedModel} from './custom-feed'
import {PostsFeedModel} from './posts' import {PostsFeedModel} from './posts'
import {PostsFeedSliceModel} from './post' import {PostsFeedSliceModel} from './post'
const FEED_PAGE_SIZE = 5 const FEED_PAGE_SIZE = 10
const FEEDS_PAGE_SIZE = 3 const FEEDS_PAGE_SIZE = 3
export type MultiFeedItem = export type MultiFeedItem =
@ -147,6 +147,15 @@ export class PostsMultiFeedModel {
await this.loadMore(true) await this.loadMore(true)
} }
/**
* Load latest in the active feeds
*/
loadLatest() {
for (const feed of this.feeds) {
/* dont await */ feed.refresh()
}
}
/** /**
* Load more posts to the end of the feed * Load more posts to the end of the feed
*/ */

View File

@ -6,15 +6,8 @@ import {
} from '@atproto/api' } from '@atproto/api'
import AwaitLock from 'await-lock' import AwaitLock from 'await-lock'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
import sampleSize from 'lodash.samplesize'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {SUGGESTED_FOLLOWS} from 'lib/constants'
import {
getCombinedCursors,
getMultipleAuthorsPosts,
mergePosts,
} from 'lib/api/build-suggested-posts'
import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
import {PostsFeedSliceModel} from './post' import {PostsFeedSliceModel} from './post'
@ -49,7 +42,7 @@ export class PostsFeedModel {
constructor( constructor(
public rootStore: RootStoreModel, public rootStore: RootStoreModel,
public feedType: 'home' | 'author' | 'suggested' | 'custom', public feedType: 'home' | 'author' | 'custom',
params: params:
| GetTimeline.QueryParams | GetTimeline.QueryParams
| GetAuthorFeed.QueryParams | GetAuthorFeed.QueryParams
@ -121,14 +114,6 @@ export class PostsFeedModel {
this.tuner.reset() this.tuner.reset()
} }
switchFeedType(feedType: 'home' | 'suggested') {
if (this.feedType === feedType) {
return
}
this.feedType = feedType
return this.setup()
}
get feedTuners() { get feedTuners() {
if (this.feedType === 'custom') { if (this.feedType === 'custom') {
return [ return [
@ -263,7 +248,7 @@ export class PostsFeedModel {
* Check if new posts are available * Check if new posts are available
*/ */
async checkForLatest() { async checkForLatest() {
if (this.hasNewLatest || this.feedType === 'suggested') { if (this.hasNewLatest) {
return return
} }
const res = await this._getFeed({limit: this.pageSize}) const res = await this._getFeed({limit: this.pageSize})
@ -415,30 +400,20 @@ export class PostsFeedModel {
GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response
> { > {
params = Object.assign({}, this.params, params) params = Object.assign({}, this.params, params)
if (this.feedType === 'suggested') { if (this.feedType === 'home') {
const responses = await getMultipleAuthorsPosts(
this.rootStore,
sampleSize(SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), 20),
params.cursor,
20,
)
const combinedCursor = getCombinedCursors(responses)
const finalData = mergePosts(responses, {bestOfOnly: true})
const lastHeaders = responses[responses.length - 1].headers
return {
success: true,
data: {
feed: finalData,
cursor: combinedCursor,
},
headers: lastHeaders,
}
} else if (this.feedType === 'home') {
return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams)
} else if (this.feedType === 'custom') { } else if (this.feedType === 'custom') {
return this.rootStore.agent.app.bsky.feed.getFeed( const res = await this.rootStore.agent.app.bsky.feed.getFeed(
params as GetCustomFeed.QueryParams, params as GetCustomFeed.QueryParams,
) )
// NOTE
// some custom feeds fail to enforce the pagination limit
// so we manually truncate here
// -prf
if (params.limit && res.data.feed.length > params.limit) {
res.data.feed = res.data.feed.slice(0, params.limit)
}
return res
} else { } else {
return this.rootStore.agent.getAuthorFeed( return this.rootStore.agent.getAuthorFeed(
params as GetAuthorFeed.QueryParams, params as GetAuthorFeed.QueryParams,

View File

@ -14,11 +14,13 @@ import {PostsMultiFeedModel} from 'state/models/feeds/multi-feed'
import {MultiFeed} from 'view/com/posts/MultiFeed' import {MultiFeed} from 'view/com/posts/MultiFeed'
import {isDesktopWeb} from 'platform/detection' import {isDesktopWeb} from 'platform/detection'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useTimer} from 'lib/hooks/useTimer'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {ComposeIcon2, CogIcon} from 'lib/icons' import {ComposeIcon2, CogIcon} from 'lib/icons'
import {s} from 'lib/styles' import {s} from 'lib/styles'
const LOAD_NEW_PROMPT_TIME = 60e3 // 60 seconds
const HEADER_OFFSET = isDesktopWeb ? 0 : 40 const HEADER_OFFSET = isDesktopWeb ? 0 : 40
type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'>
@ -33,11 +35,24 @@ export const FeedsScreen = withAuthRequired(
) )
const [onMainScroll, isScrolledDown, resetMainScroll] = const [onMainScroll, isScrolledDown, resetMainScroll] =
useOnMainScroll(store) useOnMainScroll(store)
const [loadPromptVisible, setLoadPromptVisible] = React.useState(false)
const [resetPromptTimer] = useTimer(LOAD_NEW_PROMPT_TIME, () => {
setLoadPromptVisible(true)
})
const onSoftReset = React.useCallback(() => { const onSoftReset = React.useCallback(() => {
flatListRef.current?.scrollToOffset({offset: 0}) flatListRef.current?.scrollToOffset({offset: 0})
multifeed.loadLatest()
resetPromptTimer()
setLoadPromptVisible(false)
resetMainScroll() resetMainScroll()
}, [flatListRef, resetMainScroll]) }, [
flatListRef,
resetMainScroll,
multifeed,
resetPromptTimer,
setLoadPromptVisible,
])
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
@ -99,11 +114,11 @@ export const FeedsScreen = withAuthRequired(
hideOnScroll hideOnScroll
renderButton={renderHeaderBtn} renderButton={renderHeaderBtn}
/> />
{isScrolledDown ? ( {isScrolledDown || loadPromptVisible ? (
<LoadLatestBtn <LoadLatestBtn
onPress={onSoftReset} onPress={onSoftReset}
label="Scroll to top" label="Load latest posts"
showIndicator={false} showIndicator={loadPromptVisible}
/> />
) : null} ) : null}
<FAB <FAB

View File

@ -52,8 +52,15 @@ export const HomeScreen = withAuthRequired(
model.setup() model.setup()
feeds.push(model) feeds.push(model)
} }
pagerRef.current?.setPage(0)
setCustomFeeds(feeds) setCustomFeeds(feeds)
}, [store, store.me.savedFeeds.pinned, customFeeds, setCustomFeeds]) }, [
store,
store.me.savedFeeds.pinned,
customFeeds,
setCustomFeeds,
pagerRef,
])
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {