From 5a945c2024855b89dfb99f81a2c4d226bb39dc32 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 20 Sep 2023 19:47:56 -0700 Subject: Prefilter the mergefeed to ensure a better mix of following and custom feeds (#1498) * Prefilter the mergefeed to ensure a better mix of following and custom feeds * Test suite improvements & tests for the mergefeed (#1499) * Disable invite codes test for now * Update test sim to latest iphone * Introduce TestCtrls driver * Add mergefeed tests --- src/App.native.tsx | 2 + src/lib/api/feed-manip.ts | 33 +++++++++----- src/lib/api/feed/merge.ts | 31 ++++++++++--- src/lib/constants.ts | 10 ++++- src/state/models/feeds/posts.ts | 63 +++++--------------------- src/state/models/ui/preferences.ts | 47 ++++++++++++++++++++ src/view/com/modals/ProfilePreview.tsx | 2 +- src/view/com/pager/FeedsTabBarMobile.tsx | 1 + src/view/com/posts/FeedItem.tsx | 1 + src/view/com/profile/ProfileHeader.tsx | 1 + src/view/com/search/HeaderWithInput.tsx | 1 + src/view/com/testing/TestCtrls.e2e.tsx | 76 ++++++++++++++++++++++++++++++++ src/view/com/testing/TestCtrls.tsx | 3 ++ src/view/com/util/forms/ToggleButton.tsx | 4 +- src/view/screens/PreferencesHomeFeed.tsx | 1 + 15 files changed, 203 insertions(+), 73 deletions(-) create mode 100644 src/view/com/testing/TestCtrls.e2e.tsx create mode 100644 src/view/com/testing/TestCtrls.tsx (limited to 'src') diff --git a/src/App.native.tsx b/src/App.native.tsx index d43155bf3..f99e976ce 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -18,6 +18,7 @@ import * as Toast from './view/com/util/Toast' import {handleLink} from './Navigation' import {QueryClientProvider} from '@tanstack/react-query' import {queryClient} from 'lib/react-query' +import {TestCtrls} from 'view/com/testing/TestCtrls' SplashScreen.preventAutoHideAsync() @@ -59,6 +60,7 @@ const App = observer(function AppImpl() { + diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index ef57fc4f2..8f259a910 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -128,23 +128,32 @@ export class FeedTuner { tune( feed: FeedViewPost[], tunerFns: FeedTunerFn[] = [], - {dryRun}: {dryRun: boolean} = {dryRun: false}, + {dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = { + dryRun: false, + maintainOrder: false, + }, ): FeedViewPostsSlice[] { let slices: FeedViewPostsSlice[] = [] - // arrange the posts into thread slices - for (let i = feed.length - 1; i >= 0; i--) { - const item = feed[i] - - const selfReplyUri = getSelfReplyUri(item) - if (selfReplyUri) { - const parent = slices.find(item2 => item2.isNextInThread(selfReplyUri)) - if (parent) { - parent.insert(item) - continue + if (maintainOrder) { + slices = feed.map(item => new FeedViewPostsSlice([item])) + } else { + // arrange the posts into thread slices + for (let i = feed.length - 1; i >= 0; i--) { + const item = feed[i] + + const selfReplyUri = getSelfReplyUri(item) + if (selfReplyUri) { + const parent = slices.find(item2 => + item2.isNextInThread(selfReplyUri), + ) + if (parent) { + parent.insert(item) + continue + } } + slices.unshift(new FeedViewPostsSlice([item])) } - slices.unshift(new FeedViewPostsSlice([item])) } // run the custom tuners diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index 51a619589..f93278263 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -4,6 +4,7 @@ import {RootStoreModel} from 'state/index' import {timeout} from 'lib/async/timeout' import {bundleAsync} from 'lib/async/bundle' import {feedUriToHref} from 'lib/strings/url-helpers' +import {FeedTuner} from '../feed-manip' import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types' const REQUEST_WAIT_MS = 500 // 500ms @@ -43,7 +44,7 @@ export class MergeFeedAPI implements FeedAPI { // always keep following topped up if (this.following.numReady < limit) { - promises.push(this.following.fetchNext(30)) + promises.push(this.following.fetchNext(60)) } // pick the next feeds to sample from @@ -84,7 +85,8 @@ export class MergeFeedAPI implements FeedAPI { const i = this.itemCursor++ const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0) const canSample = candidateFeeds.length > 0 - const hasFollows = this.following.numReady > 0 + const hasFollows = this.following.hasMore + const hasFollowsReady = this.following.numReady > 0 // this condition establishes the frequency that custom feeds are woven into follows const shouldSample = @@ -98,7 +100,11 @@ export class MergeFeedAPI implements FeedAPI { // time to sample, or the user isnt following anybody return candidateFeeds[this.sampleCursor++ % candidateFeeds.length].take(1) } - // not time to sample + if (!hasFollowsReady) { + // stop here so more follows can be fetched + return [] + } + // provide follow return this.following.take(1) } @@ -174,6 +180,13 @@ class MergeFeedSource { } class MergeFeedSource_Following extends MergeFeedSource { + tuner = new FeedTuner() + + reset() { + super.reset() + this.tuner.reset() + } + async fetchNext(n: number) { return this._fetchNextInner(n) } @@ -183,10 +196,16 @@ class MergeFeedSource_Following extends MergeFeedSource { limit: number, ): Promise { const res = await this.rootStore.agent.getTimeline({cursor, limit}) - // filter out mutes pre-emptively to ensure better mixing - res.data.feed = res.data.feed.filter( - post => !post.post.author.viewer?.muted, + // run the tuner pre-emptively to ensure better mixing + const slices = this.tuner.tune( + res.data.feed, + this.rootStore.preferences.getFeedTuners('home'), + { + dryRun: false, + maintainOrder: true, + }, ) + res.data.feed = slices.map(slice => slice.rootItem) return res } } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 001cdf8c3..1a7949e6a 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -83,8 +83,14 @@ export async function DEFAULT_FEEDS( // local dev const aliceDid = await resolveHandle('alice.test') return { - pinned: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], - saved: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], + pinned: [ + `at://${aliceDid}/app.bsky.feed.generator/alice-favs`, + `at://${aliceDid}/app.bsky.feed.generator/alice-favs2`, + ], + saved: [ + `at://${aliceDid}/app.bsky.feed.generator/alice-favs`, + `at://${aliceDid}/app.bsky.feed.generator/alice-favs2`, + ], } } else if (IS_STAGING(serviceUrl)) { // staging diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index d4e62533e..bb619147f 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -139,53 +139,6 @@ export class PostsFeedModel { this.tuner.reset() } - get feedTuners() { - const areRepliesEnabled = this.rootStore.preferences.homeFeedRepliesEnabled - const areRepliesByFollowedOnlyEnabled = - this.rootStore.preferences.homeFeedRepliesByFollowedOnlyEnabled - const repliesThreshold = this.rootStore.preferences.homeFeedRepliesThreshold - const areRepostsEnabled = this.rootStore.preferences.homeFeedRepostsEnabled - const areQuotePostsEnabled = - this.rootStore.preferences.homeFeedQuotePostsEnabled - - if (this.feedType === 'custom') { - return [ - FeedTuner.dedupReposts, - FeedTuner.preferredLangOnly( - this.rootStore.preferences.contentLanguages, - ), - ] - } - if (this.feedType === 'home' || this.feedType === 'following') { - const feedTuners = [] - - if (areRepostsEnabled) { - feedTuners.push(FeedTuner.dedupReposts) - } else { - feedTuners.push(FeedTuner.removeReposts) - } - - if (areRepliesEnabled) { - feedTuners.push( - FeedTuner.thresholdRepliesOnly({ - userDid: this.rootStore.session.data?.did || '', - minLikes: repliesThreshold, - followedOnly: areRepliesByFollowedOnlyEnabled, - }), - ) - } else { - feedTuners.push(FeedTuner.removeReplies) - } - - if (!areQuotePostsEnabled) { - feedTuners.push(FeedTuner.removeQuotePosts) - } - - return feedTuners - } - return [] - } - /** * Load for first render */ @@ -275,9 +228,14 @@ export class PostsFeedModel { } const post = await this.api.peekLatest() if (post) { - const slices = this.tuner.tune([post], this.feedTuners, { - dryRun: true, - }) + const slices = this.tuner.tune( + [post], + this.rootStore.preferences.getFeedTuners(this.feedType), + { + dryRun: true, + maintainOrder: true, + }, + ) if (slices[0]) { const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0]) if (sliceModel.moderation.content.filter) { @@ -363,7 +321,10 @@ export class PostsFeedModel { const slices = this.options.isSimpleFeed ? res.feed.map(item => new FeedViewPostsSlice([item])) - : this.tuner.tune(res.feed, this.feedTuners) + : this.tuner.tune( + res.feed, + this.rootStore.preferences.getFeedTuners(this.feedType), + ) const toAppend: PostsFeedSliceModel[] = [] for (const slice of slices) { diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 5c6ea230b..5e07685ca 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -8,6 +8,7 @@ import {ModerationOpts} from '@atproto/api' import {DEFAULT_FEEDS} from 'lib/constants' import {deviceLocales} from 'platform/detection' import {getAge} from 'lib/strings/time' +import {FeedTuner} from 'lib/api/feed-manip' import {LANGUAGES} from '../../../locale/languages' // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf @@ -540,6 +541,52 @@ export class PreferencesModel { toggleRequireAltTextEnabled() { this.requireAltTextEnabled = !this.requireAltTextEnabled } + + getFeedTuners( + feedType: 'home' | 'following' | 'author' | 'custom' | 'likes', + ) { + const areRepliesEnabled = this.homeFeedRepliesEnabled + const areRepliesByFollowedOnlyEnabled = + this.homeFeedRepliesByFollowedOnlyEnabled + const repliesThreshold = this.homeFeedRepliesThreshold + const areRepostsEnabled = this.homeFeedRepostsEnabled + const areQuotePostsEnabled = this.homeFeedQuotePostsEnabled + + if (feedType === 'custom') { + return [ + FeedTuner.dedupReposts, + FeedTuner.preferredLangOnly(this.contentLanguages), + ] + } + if (feedType === 'home' || feedType === 'following') { + const feedTuners = [] + + if (areRepostsEnabled) { + feedTuners.push(FeedTuner.dedupReposts) + } else { + feedTuners.push(FeedTuner.removeReposts) + } + + if (areRepliesEnabled) { + feedTuners.push( + FeedTuner.thresholdRepliesOnly({ + userDid: this.rootStore.session.data?.did || '', + minLikes: repliesThreshold, + followedOnly: areRepliesByFollowedOnlyEnabled, + }), + ) + } else { + feedTuners.push(FeedTuner.removeReplies) + } + + if (!areQuotePostsEnabled) { + feedTuners.push(FeedTuner.removeQuotePosts) + } + + return feedTuners + } + return [] + } } // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx index e0b3ec072..225a3972b 100644 --- a/src/view/com/modals/ProfilePreview.tsx +++ b/src/view/com/modals/ProfilePreview.tsx @@ -35,7 +35,7 @@ export const Component = observer(function ProfilePreviewImpl({ }, [model, screen]) return ( - + {query ? ( { + await store.session.login({ + service: 'http://localhost:3000', + identifier: 'alice.test', + password: 'hunter2', + }) + } + const onPressSignInBob = async () => { + await store.session.login({ + service: 'http://localhost:3000', + identifier: 'bob.test', + password: 'hunter2', + }) + } + return ( + + + + navigate('Home')} + accessibilityRole="button" + style={BTN} + /> + navigate('Settings')} + accessibilityRole="button" + style={BTN} + /> + navigate('Moderation')} + accessibilityRole="button" + style={BTN} + /> + store.preferences.toggleHomeFeedMergeFeedEnabled()} + accessibilityRole="button" + style={BTN} + /> + store.me.mainFeed.refresh()} + accessibilityRole="button" + style={BTN} + /> + + ) +} diff --git a/src/view/com/testing/TestCtrls.tsx b/src/view/com/testing/TestCtrls.tsx new file mode 100644 index 000000000..36fc48327 --- /dev/null +++ b/src/view/com/testing/TestCtrls.tsx @@ -0,0 +1,3 @@ +export function TestCtrls() { + return null +} diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx index 46ceb8c81..c98e846cd 100644 --- a/src/view/com/util/forms/ToggleButton.tsx +++ b/src/view/com/util/forms/ToggleButton.tsx @@ -8,6 +8,7 @@ import {colors} from 'lib/styles' import {TypographyVariant} from 'lib/ThemeContext' export function ToggleButton({ + testID, type = 'default-light', label, isSelected, @@ -15,6 +16,7 @@ export function ToggleButton({ labelType, onPress, }: { + testID?: string type?: ButtonType label: string isSelected: boolean @@ -134,7 +136,7 @@ export function ToggleButton({ }, }) return ( -