From 3217c7ff32b778d8a2a141fabdc6eabdf5ba018d Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 2 Jun 2023 09:48:53 -0500 Subject: [PATCH] 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 --- src/lib/api/build-suggested-posts.ts | 137 --------------------------- src/lib/hooks/useTimer.ts | 32 +++++++ src/lib/icons.tsx | 12 +-- src/lib/routes/helpers.ts | 1 + src/state/models/feeds/multi-feed.ts | 11 ++- src/state/models/feeds/posts.ts | 49 +++------- src/view/screens/Feeds.tsx | 23 ++++- src/view/screens/Home.tsx | 9 +- 8 files changed, 88 insertions(+), 186 deletions(-) delete mode 100644 src/lib/api/build-suggested-posts.ts create mode 100644 src/lib/hooks/useTimer.ts diff --git a/src/lib/api/build-suggested-posts.ts b/src/lib/api/build-suggested-posts.ts deleted file mode 100644 index 554869d9..00000000 --- a/src/lib/api/build-suggested-posts.ts +++ /dev/null @@ -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, -} diff --git a/src/lib/hooks/useTimer.ts b/src/lib/hooks/useTimer.ts new file mode 100644 index 00000000..bf3ecc07 --- /dev/null +++ b/src/lib/hooks/useTimer.ts @@ -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] +} diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index 8fa8e11d..dab95035 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -801,8 +801,8 @@ export function SquarePlusIcon({ height={size || 24} style={style}> { params = Object.assign({}, this.params, params) - if (this.feedType === 'suggested') { - 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') { + if (this.feedType === 'home') { return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) } 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, ) + // 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 { return this.rootStore.agent.getAuthorFeed( params as GetAuthorFeed.QueryParams, diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 169f8879..7d445238 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -14,11 +14,13 @@ import {PostsMultiFeedModel} from 'state/models/feeds/multi-feed' import {MultiFeed} from 'view/com/posts/MultiFeed' import {isDesktopWeb} from 'platform/detection' import {usePalette} from 'lib/hooks/usePalette' +import {useTimer} from 'lib/hooks/useTimer' import {useStores} from 'state/index' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {ComposeIcon2, CogIcon} from 'lib/icons' import {s} from 'lib/styles' +const LOAD_NEW_PROMPT_TIME = 60e3 // 60 seconds const HEADER_OFFSET = isDesktopWeb ? 0 : 40 type Props = NativeStackScreenProps @@ -33,11 +35,24 @@ export const FeedsScreen = withAuthRequired( ) const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store) + const [loadPromptVisible, setLoadPromptVisible] = React.useState(false) + const [resetPromptTimer] = useTimer(LOAD_NEW_PROMPT_TIME, () => { + setLoadPromptVisible(true) + }) const onSoftReset = React.useCallback(() => { flatListRef.current?.scrollToOffset({offset: 0}) + multifeed.loadLatest() + resetPromptTimer() + setLoadPromptVisible(false) resetMainScroll() - }, [flatListRef, resetMainScroll]) + }, [ + flatListRef, + resetMainScroll, + multifeed, + resetPromptTimer, + setLoadPromptVisible, + ]) useFocusEffect( React.useCallback(() => { @@ -99,11 +114,11 @@ export const FeedsScreen = withAuthRequired( hideOnScroll renderButton={renderHeaderBtn} /> - {isScrolledDown ? ( + {isScrolledDown || loadPromptVisible ? ( ) : null} {