diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-09-20 19:47:56 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-09-20 19:47:56 -0700 |
commit | 5a945c2024855b89dfb99f81a2c4d226bb39dc32 (patch) | |
tree | 3a42e8e8d79c281606c2b7d9bff9380df596d8c7 | |
parent | 68dd3210d11bf8a15c319768d3e338c629a69d4b (diff) | |
download | voidsky-5a945c2024855b89dfb99f81a2c4d226bb39dc32.tar.zst |
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
30 files changed, 519 insertions, 165 deletions
diff --git a/.detoxrc.js b/.detoxrc.js index 2968b94f9..1e41165da 100644 --- a/.detoxrc.js +++ b/.detoxrc.js @@ -41,7 +41,7 @@ module.exports = { simulator: { type: 'ios.simulator', device: { - type: 'iPhone 14', + type: 'iPhone 15', }, }, attached: { diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts index 44d33bc32..6613f54d0 100644 --- a/__e2e__/mock-server.ts +++ b/__e2e__/mock-server.ts @@ -55,7 +55,7 @@ async function main() { } if ('feeds' in url.query) { console.log('Generating mock feed') - await server.mocker.createFeed('alice') + await server.mocker.createFeed('alice', 'alice-favs', []) } if ('thread' in url.query) { console.log('Generating mock posts') @@ -70,6 +70,82 @@ async function main() { }, }) } + if ('mergefeed' in url.query) { + console.log('Generating mock users') + await server.mocker.createUser('alice') + await server.mocker.createUser('bob') + await server.mocker.createUser('carla') + await server.mocker.createUser('dan') + await server.mocker.users.alice.agent.upsertProfile(() => ({ + displayName: 'Alice', + description: 'Test user 1', + })) + await server.mocker.users.bob.agent.upsertProfile(() => ({ + displayName: 'Bob', + description: 'Test user 2', + })) + await server.mocker.users.carla.agent.upsertProfile(() => ({ + displayName: 'Carla', + description: 'Test user 3', + })) + await server.mocker.users.dan.agent.upsertProfile(() => ({ + displayName: 'Dan', + description: 'Test user 4', + })) + console.log('Generating mock follows') + await server.mocker.follow('alice', 'bob') + await server.mocker.follow('alice', 'carla') + console.log('Generating mock posts') + let posts: Record<string, any[]> = { + alice: [], + bob: [], + carla: [], + dan: [], + } + for (let i = 0; i < 10; i++) { + for (let user in server.mocker.users) { + if (user === 'alice') continue + posts[user].push( + await server.mocker.createPost(user, `Post ${i}`), + ) + } + } + for (let i = 0; i < 10; i++) { + for (let user in server.mocker.users) { + if (user === 'alice') continue + if (i % 5 === 0) { + await server.mocker.createReply(user, 'Self reply', { + cid: posts[user][i].cid, + uri: posts[user][i].uri, + }) + } + if (i % 5 === 1) { + await server.mocker.createReply(user, 'Reply to bob', { + cid: posts.bob[i].cid, + uri: posts.bob[i].uri, + }) + } + if (i % 5 === 2) { + await server.mocker.createReply(user, 'Reply to dan', { + cid: posts.dan[i].cid, + uri: posts.dan[i].uri, + }) + } + await server.mocker.users[user].agent.post({text: `Post ${i}`}) + } + } + console.log('Generating mock feeds') + await server.mocker.createFeed( + 'alice', + 'alice-favs', + posts.dan.map(p => p.uri), + ) + await server.mocker.createFeed( + 'alice', + 'alice-favs2', + posts.dan.map(p => p.uri), + ) + } if ('labels' in url.query) { console.log('Generating naughty users with labels') diff --git a/__e2e__/tests/composer.test.ts b/__e2e__/tests/composer.test.ts index afc23cc13..6251ad0c8 100644 --- a/__e2e__/tests/composer.test.ts +++ b/__e2e__/tests/composer.test.ts @@ -1,18 +1,17 @@ /* eslint-env detox/detox */ -import {openApp, login, createServer, sleep} from '../util' +import {openApp, loginAsAlice, createServer, sleep} from '../util' describe('Composer', () => { - let service: string beforeAll(async () => { - service = await createServer('?users') + await createServer('?users') await openApp({ permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, }) }) it('Login', async () => { - await login(service, 'alice', 'hunter2') + await loginAsAlice() await element(by.id('homeScreenFeedTabs-Following')).tap() }) diff --git a/__e2e__/tests/home-screen.test.ts b/__e2e__/tests/home-screen.test.ts index 7bad3c19a..7647b55cb 100644 --- a/__e2e__/tests/home-screen.test.ts +++ b/__e2e__/tests/home-screen.test.ts @@ -1,16 +1,15 @@ /* eslint-env detox/detox */ -import {openApp, login, createServer} from '../util' +import {openApp, loginAsAlice, createServer} from '../util' describe('Home screen', () => { - let service: string beforeAll(async () => { - service = await createServer('?users&follows&posts') + await createServer('?users&follows&posts') await openApp({permissions: {notifications: 'YES'}}) }) it('Login', async () => { - await login(service, 'alice', 'hunter2') + await loginAsAlice() await element(by.id('homeScreenFeedTabs-Following')).tap() }) diff --git a/__e2e__/tests/invite-codes.test.ts b/__e2e__/tests/invite-codes.test-skip.ts index efe7b9d12..f5d2bafb3 100644 --- a/__e2e__/tests/invite-codes.test.ts +++ b/__e2e__/tests/invite-codes.test-skip.ts @@ -1,6 +1,11 @@ /* eslint-env detox/detox */ -import {openApp, login, createServer} from '../util' +/** + * This test is being skipped until we can resolve the detox crash issue + * with the side drawer. + */ + +import {openApp, loginAsAlice, createServer} from '../util' describe('invite-codes', () => { let service: string @@ -12,7 +17,7 @@ describe('invite-codes', () => { it('I can fetch invite codes', async () => { await expect(element(by.id('signInButton'))).toBeVisible() - await login(service, 'alice', 'hunter2') + await loginAsAlice() await element(by.id('viewHeaderDrawerBtn')).tap() await expect(element(by.id('drawer'))).toBeVisible() await element(by.id('menuItemInviteCodes')).tap() @@ -47,15 +52,10 @@ describe('invite-codes', () => { await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible() await element(by.id('continueBtn')).tap() await expect(element(by.id('homeScreen'))).toBeVisible() - await element(by.id('viewHeaderDrawerBtn')).tap() - await element(by.id('menuItemButton-Settings')).tap() - await element(by.id('signOutBtn')).tap() }) it('I get a notification for the new user', async () => { - await expect(element(by.id('signInButton'))).toBeVisible() - await login(service, 'alice', 'hunter2') - await element(by.id('viewHeaderDrawerBtn')).tap() + await loginAsAlice() await element(by.id('menuItemButton-Notifications')).tap() await expect(element(by.id('invitedUser'))).toBeVisible() }) diff --git a/__e2e__/tests/merge-feed.test.ts b/__e2e__/tests/merge-feed.test.ts new file mode 100644 index 000000000..903e34328 --- /dev/null +++ b/__e2e__/tests/merge-feed.test.ts @@ -0,0 +1,157 @@ +/* eslint-env detox/detox */ + +import {openApp, loginAsAlice, createServer} from '../util' + +describe('Mergefeed', () => { + beforeAll(async () => { + await createServer('?mergefeed') + await openApp({permissions: {notifications: 'YES'}}) + }) + + it('Login', async () => { + await loginAsAlice() + await element(by.id('e2eToggleMergefeed')).tap() + }) + + it('Sees the expected mix of posts with default filters', async () => { + await element(by.id('followingFeedPage-feed-flatlist')).swipe( + 'down', + 'slow', + 1, + 0.5, + 0.5, + ) + // followed users + await expect( + element( + by.id('postText').withAncestor(by.id('feedItem-by-carla.test')), + ).atIndex(0), + ).toHaveText('Post 9') + await expect( + element( + by.id('postText').withAncestor(by.id('feedItem-by-bob.test')), + ).atIndex(0), + ).toHaveText('Post 9') + await element(by.id('followingFeedPage-feed-flatlist')).swipe( + 'up', + 'fast', + 1, + 0.5, + 0.5, + ) + // feed users + await expect( + element( + by.id('postText').withAncestor(by.id('feedItem-by-dan.test')), + ).atIndex(0), + ).toHaveText('Post 0') + }) + + it('Sees the expected mix of posts with replies disabled', async () => { + await element(by.id('followingFeedPage-feed-flatlist')).swipe( + 'down', + 'fast', + 1, + 0.5, + 0.5, + ) + await element(by.id('followingFeedPage-feed-flatlist')).swipe( + 'down', + 'fast', + 1, + 0.5, + 0.5, + ) + await element(by.id('viewHeaderHomeFeedPrefsBtn')).tap() + await element(by.id('toggleRepliesBtn')).tap() + await element(by.id('confirmBtn')).tap() + await element(by.id('followingFeedPage-feed-flatlist')).swipe( + 'down', + 'slow', + 1, + 0.5, + 0.5, + ) + + // followed users + await expect( + element( + by.id('postText').withAncestor(by.id('feedItem-by-carla.test')), + ).atIndex(0), + ).toHaveText('Post 9') + await expect( + element( + by.id('postText').withAncestor(by.id('feedItem-by-bob.test')), + ).atIndex(0), + ).toHaveText('Post 9') + await element(by.id('followingFeedPage-feed-flatlist')).swipe( + 'up', + 'fast', + 1, + 0.5, + 0.5, + ) + + // feed users + await expect( + element( + by.id('postText').withAncestor(by.id('feedItem-by-dan.test')), + ).atIndex(0), + ).toHaveText('Post 0') + }) + + it('Sees the expected mix of posts with no follows', async () => { + await element(by.id('followingFeedPage-feed-flatlist')).swipe( + 'down', + 'fast', + 1, + 0.5, + 0.5, + ) + + await element(by.id('bottomBarSearchBtn')).tap() + await element(by.id('searchTextInput')).typeText('bob') + await element(by.id('searchAutoCompleteResult-bob.test')).tap() + await expect(element(by.id('profileView'))).toBeVisible() + await element(by.id('unfollowBtn')).tap() + await element(by.id('profileHeaderBackBtn')).tap() + + // have to wait for the toast to clear + await waitFor(element(by.id('searchTextInputClearBtn'))) + .toBeVisible() + .withTimeout(5000) + await element(by.id('searchTextInputClearBtn')).tap() + await element(by.id('searchTextInput')).typeText('carla') + await element(by.id('searchAutoCompleteResult-carla.test')).tap() + await expect(element(by.id('profileView'))).toBeVisible() + await element(by.id('unfollowBtn')).tap() + await element(by.id('profileHeaderBackBtn')).tap() + + await element(by.id('bottomBarHomeBtn')).tap() + await element(by.id('followingFeedPage-feed-flatlist')).swipe( + 'down', + 'slow', + 1, + 0.5, + 0.5, + ) + await element(by.id('followingFeedPage-feed-flatlist')).swipe( + 'down', + 'slow', + 1, + 0.5, + 0.5, + ) + + // followed users NOT present + await expect(element(by.id('feedItem-by-carla.test'))).not.toExist() + await expect(element(by.id('feedItem-by-bob.test'))).not.toExist() + + // feed users + await expect( + element( + by.id('postText').withAncestor(by.id('feedItem-by-dan.test')), + ).atIndex(0), + ).toHaveText('Post 0') + }) +}) diff --git a/__e2e__/tests/mute-lists.test.ts b/__e2e__/tests/mute-lists.test.ts index 1fd3dc328..6c46de0ec 100644 --- a/__e2e__/tests/mute-lists.test.ts +++ b/__e2e__/tests/mute-lists.test.ts @@ -1,11 +1,10 @@ /* eslint-env detox/detox */ -import {openApp, login, createServer, sleep} from '../util' +import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util' describe('Mute lists', () => { - let service: string beforeAll(async () => { - service = await createServer('?users&follows&labels') + await createServer('?users&follows&labels') await openApp({ permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, }) @@ -13,10 +12,8 @@ describe('Mute lists', () => { it('Login and view my mutelists', async () => { await expect(element(by.id('signInButton'))).toBeVisible() - await login(service, 'alice', 'hunter2') - await element(by.id('viewHeaderDrawerBtn')).tap() - await expect(element(by.id('drawer'))).toBeVisible() - await element(by.id('menuItemButton-Moderation')).tap() + await loginAsAlice() + await element(by.id('e2eGotoModeration')).tap() await element(by.id('mutelistsBtn')).tap() await expect(element(by.id('list-Muted Users'))).toBeVisible() await element(by.id('list-Muted Users')).tap() @@ -141,19 +138,9 @@ describe('Mute lists', () => { }) it('Can report a mute list', async () => { - await element(by.id('bottomBarHomeBtn')).tap() - // Last test leaves us in the list view so we are going back 1 screen to the lists list screen - await element(by.id('viewHeaderDrawerBtn')).tap() - // then to the moderation screen - await element(by.id('viewHeaderDrawerBtn')).tap() - // then to the home screen - await element(by.id('viewHeaderDrawerBtn')).tap() - // then open the drawer to go to settings - await element(by.id('viewHeaderDrawerBtn')).tap() - await element(by.id('menuItemButton-Settings')).tap() + await element(by.id('e2eGotoSettings')).tap() await element(by.id('signOutBtn')).tap() - await expect(element(by.id('signInButton'))).toBeVisible() - await login(service, 'bob.test', 'hunter2') + await loginAsBob() await element(by.id('bottomBarSearchBtn')).tap() await element(by.id('searchTextInput')).typeText('alice') await element(by.id('searchAutoCompleteResult-alice.test')).tap() diff --git a/__e2e__/tests/profile-screen.test.ts b/__e2e__/tests/profile-screen.test.ts index 92ed2dc65..101aaf61c 100644 --- a/__e2e__/tests/profile-screen.test.ts +++ b/__e2e__/tests/profile-screen.test.ts @@ -1,11 +1,10 @@ /* eslint-env detox/detox */ -import {openApp, login, createServer, sleep} from '../util' +import {openApp, loginAsAlice, createServer, sleep} from '../util' describe('Profile screen', () => { - let service: string beforeAll(async () => { - service = await createServer('?users&posts&feeds') + await createServer('?users&posts&feeds') await openApp({ permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, }) @@ -13,7 +12,7 @@ describe('Profile screen', () => { it('Login and navigate to my profile', async () => { await expect(element(by.id('signInButton'))).toBeVisible() - await login(service, 'alice', 'hunter2') + await loginAsAlice() await element(by.id('bottomBarProfileBtn')).tap() }) diff --git a/__e2e__/tests/search-screen.test.ts b/__e2e__/tests/search-screen.test.ts index 093d97c89..8b3f55b3d 100644 --- a/__e2e__/tests/search-screen.test.ts +++ b/__e2e__/tests/search-screen.test.ts @@ -1,18 +1,17 @@ /* eslint-env detox/detox */ -import {openApp, login, createServer} from '../util' +import {openApp, loginAsAlice, createServer} from '../util' describe('Search screen', () => { - let service: string beforeAll(async () => { - service = await createServer('?users') + await createServer('?users') await openApp({ permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, }) }) it('Login', async () => { - await login(service, 'alice', 'hunter2') + await loginAsAlice() }) it('Navigate to another user profile via autocomplete', async () => { diff --git a/__e2e__/tests/self-labeling.test.ts b/__e2e__/tests/self-labeling.test.ts index ba8d00f21..68678688d 100644 --- a/__e2e__/tests/self-labeling.test.ts +++ b/__e2e__/tests/self-labeling.test.ts @@ -1,18 +1,17 @@ /* eslint-env detox/detox */ -import {openApp, login, createServer, sleep} from '../util' +import {openApp, loginAsAlice, createServer, sleep} from '../util' describe('Self-labeling', () => { - let service: string beforeAll(async () => { - service = await createServer('?users') + await createServer('?users') await openApp({ permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, }) }) it('Login', async () => { - await login(service, 'alice', 'hunter2') + await loginAsAlice() await element(by.id('homeScreenFeedTabs-Following')).tap() }) diff --git a/__e2e__/tests/shell.test.ts b/__e2e__/tests/shell.test.ts index 5cfd4277f..69619dd81 100644 --- a/__e2e__/tests/shell.test.ts +++ b/__e2e__/tests/shell.test.ts @@ -1,16 +1,15 @@ /* eslint-env detox/detox */ -import {openApp, login, createServer} from '../util' +import {openApp, loginAsAlice, createServer} from '../util' describe('Shell', () => { - let service: string beforeAll(async () => { - service = await createServer('?users') + await createServer('?users') await openApp({permissions: {notifications: 'YES'}}) }) it('Login', async () => { - await login(service, 'alice', 'hunter2') + await loginAsAlice() await element(by.id('homeScreenFeedTabs-Following')).tap() }) diff --git a/__e2e__/tests/thread-muting.test.ts b/__e2e__/tests/thread-muting.test.ts index 8acd9d81f..3b2dc1221 100644 --- a/__e2e__/tests/thread-muting.test.ts +++ b/__e2e__/tests/thread-muting.test.ts @@ -1,42 +1,34 @@ /* eslint-env detox/detox */ -import {openApp, login, createServer} from '../util' +import {openApp, loginAsAlice, loginAsBob, createServer} from '../util' describe('Thread muting', () => { - let service: string beforeAll(async () => { - service = await createServer('?users&follows') + await createServer('?users&follows') await openApp({permissions: {notifications: 'YES'}}) }) it('Login, create a thread, and log out', async () => { - await login(service, 'alice', 'hunter2') + await loginAsAlice() await element(by.id('homeScreenFeedTabs-Following')).tap() await element(by.id('composeFAB')).tap() await element(by.id('composerTextInput')).typeText('Test thread') await element(by.id('composerPublishBtn')).tap() await expect(element(by.id('composeFAB'))).toBeVisible() - await element(by.id('viewHeaderDrawerBtn')).tap() - await element(by.id('menuItemButton-Settings')).tap() - await element(by.id('signOutBtn')).tap() }) it('Login, reply to the thread, and log out', async () => { - await login(service, 'bob', 'hunter2') + await loginAsBob() await element(by.id('homeScreenFeedTabs-Following')).tap() const alicePosts = by.id('feedItem-by-alice.test') await element(by.id('replyBtn').withAncestor(alicePosts)).atIndex(0).tap() await element(by.id('composerTextInput')).typeText('Reply 1') await element(by.id('composerPublishBtn')).tap() await expect(element(by.id('composeFAB'))).toBeVisible() - await element(by.id('viewHeaderDrawerBtn')).tap() - await element(by.id('menuItemButton-Settings')).tap() - await element(by.id('signOutBtn')).tap() }) it('Login, confirm notification exists, mute thread, and log out', async () => { - await login(service, 'alice', 'hunter2') - + await loginAsAlice() await element(by.id('bottomBarNotificationsBtn')).tap() const bobNotifs = by.id('feedItem-by-bob.test') await expect( @@ -50,14 +42,10 @@ describe('Thread muting', () => { await waitFor(element(by.id('viewHeaderDrawerBtn'))) .toBeVisible() .withTimeout(5000) - - await element(by.id('viewHeaderDrawerBtn')).tap() - await element(by.id('menuItemButton-Settings')).tap() - await element(by.id('signOutBtn')).tap() }) it('Login, reply to the thread twice, and log out', async () => { - await login(service, 'bob', 'hunter2') + await loginAsBob() await element(by.id('bottomBarProfileBtn')).tap() await element(by.id('selector-1')).tap() @@ -74,13 +62,10 @@ describe('Thread muting', () => { await expect(element(by.id('composeFAB'))).toBeVisible() await element(by.id('bottomBarHomeBtn')).tap() - await element(by.id('viewHeaderDrawerBtn')).tap() - await element(by.id('menuItemButton-Settings')).tap() - await element(by.id('signOutBtn')).tap() }) it('Login, confirm notifications dont exist, unmute the thread, confirm notifications exist', async () => { - await login(service, 'alice', 'hunter2') + await loginAsAlice() await element(by.id('bottomBarNotificationsBtn')).tap() const bobNotifs = by.id('feedItem-by-bob.test') @@ -93,7 +78,7 @@ describe('Thread muting', () => { await element(by.id('postDropdownBtn').withAncestor(alicePosts)) .atIndex(0) .tap() - await element(by.text('Mute thread')).tap() + await element(by.text('Unmute thread')).tap() // TODO // the swipe down to trigger PTR isnt working and I dont want to block on this diff --git a/__e2e__/tests/thread-screen.test.ts b/__e2e__/tests/thread-screen.test.ts index 0964988e9..02831d055 100644 --- a/__e2e__/tests/thread-screen.test.ts +++ b/__e2e__/tests/thread-screen.test.ts @@ -1,16 +1,15 @@ /* eslint-env detox/detox */ -import {openApp, login, createServer} from '../util' +import {openApp, loginAsAlice, createServer} from '../util' describe('Thread screen', () => { - let service: string beforeAll(async () => { - service = await createServer('?users&follows&thread') + await createServer('?users&follows&thread') await openApp({permissions: {notifications: 'YES'}}) }) it('Login & navigate to thread', async () => { - await login(service, 'alice', 'hunter2') + await loginAsAlice() await element(by.id('homeScreenFeedTabs-Following')).tap() await element(by.id('feedItem-by-bob.test')).atIndex(0).tap() await expect( diff --git a/__e2e__/util.ts b/__e2e__/util.ts index f5bb72815..f6f3b1b80 100644 --- a/__e2e__/util.ts +++ b/__e2e__/util.ts @@ -69,6 +69,14 @@ export async function login( await element(by.id('loginNextButton')).tap() } +export async function loginAsAlice() { + await element(by.id('e2eSignInAlice')).tap() +} + +export async function loginAsBob() { + await element(by.id('e2eSignInBob')).tap() +} + async function openAppForDebugBuild(platform: string, opts: any) { const deepLinkUrl = // Local testing with packager /*process.env.EXPO_USE_UPDATES diff --git a/jest/test-pds.ts b/jest/test-pds.ts index 0c9d946fc..37ad824a0 100644 --- a/jest/test-pds.ts +++ b/jest/test-pds.ts @@ -1,7 +1,7 @@ import net from 'net' import path from 'path' import fs from 'fs' -import {TestPds as DevEnvTestPDS, TestNetworkNoAppView} from '@atproto/dev-env' +import {TestNetworkNoAppView} from '@atproto/dev-env' import {AtUri, BskyAgent} from '@atproto/api' export interface TestUser { @@ -24,7 +24,7 @@ export async function createServer( const port = await getPort() const port2 = await getPort(port + 1) const pdsUrl = `http://localhost:${port}` - const {pds, plc} = await TestNetworkNoAppView.create({ + const testNet = await TestNetworkNoAppView.create({ pds: {port, publicUrl: pdsUrl, inviteRequired}, plc: {port: port2}, }) @@ -35,10 +35,10 @@ export async function createServer( return { pdsUrl, - mocker: new Mocker(pds, pdsUrl, pic), + mocker: new Mocker(testNet, pdsUrl, pic), async close() { - await pds.server.destroy() - await plc.server.destroy() + await testNet.pds.server.destroy() + await testNet.plc.server.destroy() }, } } @@ -48,13 +48,21 @@ class Mocker { users: Record<string, TestUser> = {} constructor( - public pds: DevEnvTestPDS, + public testNet: TestNetworkNoAppView, public service: string, public pic: Uint8Array, ) { this.agent = new BskyAgent({service}) } + get pds() { + return this.testNet.pds + } + + get plc() { + return this.testNet.plc + } + // NOTE // deterministic date generator // we use this to ensure the mock dataset is always the same @@ -212,24 +220,34 @@ class Mocker { return await agent.like(uri, cid) } - async createFeed(user: string) { + async createFeed(user: string, rkey: string, posts: string[]) { const agent = this.users[user]?.agent if (!agent) { throw new Error(`Not a user: ${user}`) } - const fg1Uri = AtUri.make( + const fgUri = AtUri.make( this.users[user].did, 'app.bsky.feed.generator', - 'alice-favs', + rkey, ) + const fg1 = await this.testNet.createFeedGen({ + [fgUri.toString()]: async () => { + return { + encoding: 'application/json', + body: { + feed: posts.slice(0, 30).map(uri => ({post: uri})), + }, + } + }, + }) const avatarRes = await agent.api.com.atproto.repo.uploadBlob(this.pic, { encoding: 'image/png', }) return await agent.api.app.bsky.feed.generator.create( - {repo: this.users[user].did, rkey: fg1Uri.rkey}, + {repo: this.users[user].did, rkey}, { - did: 'did:web:fake.com', - displayName: 'alices feed', + did: fg1.did, + displayName: rkey, description: 'all my fav stuff', avatar: avatarRes.data.blob, createdAt: new Date().toISOString(), 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() { <analytics.Provider> <RootStoreProvider value={rootStore}> <GestureHandlerRootView style={s.h100pct}> + <TestCtrls /> <Shell /> </GestureHandlerRootView> </RootStoreProvider> 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<AppBskyFeedGetTimeline.Response> { 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 ( - <View style={[pal.view, s.flex1]}> + <View testID="profilePreview" style={[pal.view, s.flex1]}> <View style={[ styles.headerWrapper, diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 30a712541..e39e2dd68 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -67,6 +67,7 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( </Text> <View style={[pal.view]}> <Link + testID="viewHeaderHomeFeedPrefsBtn" href="/settings/home-feed" hitSlop={HITSLOP_10} accessibilityRole="button" diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 59ab28d72..23d8546bc 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -299,6 +299,7 @@ export const FeedItem = observer(function FeedItemImpl({ {item.richText?.text ? ( <View style={styles.postTextContainer}> <RichText + testID="postText" type="post-text" richText={item.richText} lineHeight={1.3} diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 7f3e52d96..82b992551 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -556,6 +556,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ {!isDesktop && !hideBackButton && ( <TouchableWithoutFeedback + testID="profileHeaderBackBtn" onPress={onPressBack} hitSlop={BACK_HITSLOP} accessibilityRole="button" diff --git a/src/view/com/search/HeaderWithInput.tsx b/src/view/com/search/HeaderWithInput.tsx index 7a8676602..f04175afd 100644 --- a/src/view/com/search/HeaderWithInput.tsx +++ b/src/view/com/search/HeaderWithInput.tsx @@ -102,6 +102,7 @@ export function HeaderWithInput({ /> {query ? ( <TouchableOpacity + testID="searchTextInputClearBtn" onPress={onPressClearQuery} accessibilityRole="button" accessibilityLabel="Clear search query" diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx new file mode 100644 index 000000000..019c7a508 --- /dev/null +++ b/src/view/com/testing/TestCtrls.e2e.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import {Pressable, View} from 'react-native' +import {useStores} from 'state/index' +import {navigate} from '../../../Navigation' + +/** + * This utility component is only included in the test simulator + * build. It gives some quick triggers which help improve the pace + * of the tests dramatically. + */ + +const BTN = {height: 1, width: 1, backgroundColor: 'red'} + +export function TestCtrls() { + const store = useStores() + const onPressSignInAlice = async () => { + 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 ( + <View style={{position: 'absolute', top: 100, right: 0, zIndex: 100}}> + <Pressable + testID="e2eSignInAlice" + onPress={onPressSignInAlice} + accessibilityRole="button" + style={BTN} + /> + <Pressable + testID="e2eSignInBob" + onPress={onPressSignInBob} + accessibilityRole="button" + style={BTN} + /> + <Pressable + testID="e2eGotoHome" + onPress={() => navigate('Home')} + accessibilityRole="button" + style={BTN} + /> + <Pressable + testID="e2eGotoSettings" + onPress={() => navigate('Settings')} + accessibilityRole="button" + style={BTN} + /> + <Pressable + testID="e2eGotoModeration" + onPress={() => navigate('Moderation')} + accessibilityRole="button" + style={BTN} + /> + <Pressable + testID="e2eToggleMergefeed" + onPress={() => store.preferences.toggleHomeFeedMergeFeedEnabled()} + accessibilityRole="button" + style={BTN} + /> + <Pressable + testID="e2eRefreshHome" + onPress={() => store.me.mainFeed.refresh()} + accessibilityRole="button" + style={BTN} + /> + </View> + ) +} 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 ( - <Button type={type} onPress={onPress} style={style}> + <Button testID={testID} type={type} onPress={onPress} style={style}> <View style={styles.outer}> <View style={[circleStyle, styles.circle]}> <View diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx index 404d006f8..8f6e0761a 100644 --- a/src/view/screens/PreferencesHomeFeed.tsx +++ b/src/view/screens/PreferencesHomeFeed.tsx @@ -86,6 +86,7 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ Set this setting to "No" to hide all replies from your feed. </Text> <ToggleButton + testID="toggleRepliesBtn" type="default-light" label={store.preferences.homeFeedRepliesEnabled ? 'Yes' : 'No'} isSelected={store.preferences.homeFeedRepliesEnabled} |