Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

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

authored by

Paul Frazee and committed by
GitHub
5a945c20 68dd3210

+518 -164
+1 -1
.detoxrc.js
··· 41 41 simulator: { 42 42 type: 'ios.simulator', 43 43 device: { 44 - type: 'iPhone 14', 44 + type: 'iPhone 15', 45 45 }, 46 46 }, 47 47 attached: {
+77 -1
__e2e__/mock-server.ts
··· 55 55 } 56 56 if ('feeds' in url.query) { 57 57 console.log('Generating mock feed') 58 - await server.mocker.createFeed('alice') 58 + await server.mocker.createFeed('alice', 'alice-favs', []) 59 59 } 60 60 if ('thread' in url.query) { 61 61 console.log('Generating mock posts') ··· 69 69 root: {cid: res.cid, uri: res.uri}, 70 70 }, 71 71 }) 72 + } 73 + if ('mergefeed' in url.query) { 74 + console.log('Generating mock users') 75 + await server.mocker.createUser('alice') 76 + await server.mocker.createUser('bob') 77 + await server.mocker.createUser('carla') 78 + await server.mocker.createUser('dan') 79 + await server.mocker.users.alice.agent.upsertProfile(() => ({ 80 + displayName: 'Alice', 81 + description: 'Test user 1', 82 + })) 83 + await server.mocker.users.bob.agent.upsertProfile(() => ({ 84 + displayName: 'Bob', 85 + description: 'Test user 2', 86 + })) 87 + await server.mocker.users.carla.agent.upsertProfile(() => ({ 88 + displayName: 'Carla', 89 + description: 'Test user 3', 90 + })) 91 + await server.mocker.users.dan.agent.upsertProfile(() => ({ 92 + displayName: 'Dan', 93 + description: 'Test user 4', 94 + })) 95 + console.log('Generating mock follows') 96 + await server.mocker.follow('alice', 'bob') 97 + await server.mocker.follow('alice', 'carla') 98 + console.log('Generating mock posts') 99 + let posts: Record<string, any[]> = { 100 + alice: [], 101 + bob: [], 102 + carla: [], 103 + dan: [], 104 + } 105 + for (let i = 0; i < 10; i++) { 106 + for (let user in server.mocker.users) { 107 + if (user === 'alice') continue 108 + posts[user].push( 109 + await server.mocker.createPost(user, `Post ${i}`), 110 + ) 111 + } 112 + } 113 + for (let i = 0; i < 10; i++) { 114 + for (let user in server.mocker.users) { 115 + if (user === 'alice') continue 116 + if (i % 5 === 0) { 117 + await server.mocker.createReply(user, 'Self reply', { 118 + cid: posts[user][i].cid, 119 + uri: posts[user][i].uri, 120 + }) 121 + } 122 + if (i % 5 === 1) { 123 + await server.mocker.createReply(user, 'Reply to bob', { 124 + cid: posts.bob[i].cid, 125 + uri: posts.bob[i].uri, 126 + }) 127 + } 128 + if (i % 5 === 2) { 129 + await server.mocker.createReply(user, 'Reply to dan', { 130 + cid: posts.dan[i].cid, 131 + uri: posts.dan[i].uri, 132 + }) 133 + } 134 + await server.mocker.users[user].agent.post({text: `Post ${i}`}) 135 + } 136 + } 137 + console.log('Generating mock feeds') 138 + await server.mocker.createFeed( 139 + 'alice', 140 + 'alice-favs', 141 + posts.dan.map(p => p.uri), 142 + ) 143 + await server.mocker.createFeed( 144 + 'alice', 145 + 'alice-favs2', 146 + posts.dan.map(p => p.uri), 147 + ) 72 148 } 73 149 if ('labels' in url.query) { 74 150 console.log('Generating naughty users with labels')
+3 -4
__e2e__/tests/composer.test.ts
··· 1 1 /* eslint-env detox/detox */ 2 2 3 - import {openApp, login, createServer, sleep} from '../util' 3 + import {openApp, loginAsAlice, createServer, sleep} from '../util' 4 4 5 5 describe('Composer', () => { 6 - let service: string 7 6 beforeAll(async () => { 8 - service = await createServer('?users') 7 + await createServer('?users') 9 8 await openApp({ 10 9 permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, 11 10 }) 12 11 }) 13 12 14 13 it('Login', async () => { 15 - await login(service, 'alice', 'hunter2') 14 + await loginAsAlice() 16 15 await element(by.id('homeScreenFeedTabs-Following')).tap() 17 16 }) 18 17
+3 -4
__e2e__/tests/home-screen.test.ts
··· 1 1 /* eslint-env detox/detox */ 2 2 3 - import {openApp, login, createServer} from '../util' 3 + import {openApp, loginAsAlice, createServer} from '../util' 4 4 5 5 describe('Home screen', () => { 6 - let service: string 7 6 beforeAll(async () => { 8 - service = await createServer('?users&follows&posts') 7 + await createServer('?users&follows&posts') 9 8 await openApp({permissions: {notifications: 'YES'}}) 10 9 }) 11 10 12 11 it('Login', async () => { 13 - await login(service, 'alice', 'hunter2') 12 + await loginAsAlice() 14 13 await element(by.id('homeScreenFeedTabs-Following')).tap() 15 14 }) 16 15
+8 -8
__e2e__/tests/invite-codes.test.ts __e2e__/tests/invite-codes.test-skip.ts
··· 1 1 /* eslint-env detox/detox */ 2 2 3 - import {openApp, login, createServer} from '../util' 3 + /** 4 + * This test is being skipped until we can resolve the detox crash issue 5 + * with the side drawer. 6 + */ 7 + 8 + import {openApp, loginAsAlice, createServer} from '../util' 4 9 5 10 describe('invite-codes', () => { 6 11 let service: string ··· 12 17 13 18 it('I can fetch invite codes', async () => { 14 19 await expect(element(by.id('signInButton'))).toBeVisible() 15 - await login(service, 'alice', 'hunter2') 20 + await loginAsAlice() 16 21 await element(by.id('viewHeaderDrawerBtn')).tap() 17 22 await expect(element(by.id('drawer'))).toBeVisible() 18 23 await element(by.id('menuItemInviteCodes')).tap() ··· 47 52 await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible() 48 53 await element(by.id('continueBtn')).tap() 49 54 await expect(element(by.id('homeScreen'))).toBeVisible() 50 - await element(by.id('viewHeaderDrawerBtn')).tap() 51 - await element(by.id('menuItemButton-Settings')).tap() 52 - await element(by.id('signOutBtn')).tap() 53 55 }) 54 56 55 57 it('I get a notification for the new user', async () => { 56 - await expect(element(by.id('signInButton'))).toBeVisible() 57 - await login(service, 'alice', 'hunter2') 58 - await element(by.id('viewHeaderDrawerBtn')).tap() 58 + await loginAsAlice() 59 59 await element(by.id('menuItemButton-Notifications')).tap() 60 60 await expect(element(by.id('invitedUser'))).toBeVisible() 61 61 })
+157
__e2e__/tests/merge-feed.test.ts
··· 1 + /* eslint-env detox/detox */ 2 + 3 + import {openApp, loginAsAlice, createServer} from '../util' 4 + 5 + describe('Mergefeed', () => { 6 + beforeAll(async () => { 7 + await createServer('?mergefeed') 8 + await openApp({permissions: {notifications: 'YES'}}) 9 + }) 10 + 11 + it('Login', async () => { 12 + await loginAsAlice() 13 + await element(by.id('e2eToggleMergefeed')).tap() 14 + }) 15 + 16 + it('Sees the expected mix of posts with default filters', async () => { 17 + await element(by.id('followingFeedPage-feed-flatlist')).swipe( 18 + 'down', 19 + 'slow', 20 + 1, 21 + 0.5, 22 + 0.5, 23 + ) 24 + // followed users 25 + await expect( 26 + element( 27 + by.id('postText').withAncestor(by.id('feedItem-by-carla.test')), 28 + ).atIndex(0), 29 + ).toHaveText('Post 9') 30 + await expect( 31 + element( 32 + by.id('postText').withAncestor(by.id('feedItem-by-bob.test')), 33 + ).atIndex(0), 34 + ).toHaveText('Post 9') 35 + await element(by.id('followingFeedPage-feed-flatlist')).swipe( 36 + 'up', 37 + 'fast', 38 + 1, 39 + 0.5, 40 + 0.5, 41 + ) 42 + // feed users 43 + await expect( 44 + element( 45 + by.id('postText').withAncestor(by.id('feedItem-by-dan.test')), 46 + ).atIndex(0), 47 + ).toHaveText('Post 0') 48 + }) 49 + 50 + it('Sees the expected mix of posts with replies disabled', async () => { 51 + await element(by.id('followingFeedPage-feed-flatlist')).swipe( 52 + 'down', 53 + 'fast', 54 + 1, 55 + 0.5, 56 + 0.5, 57 + ) 58 + await element(by.id('followingFeedPage-feed-flatlist')).swipe( 59 + 'down', 60 + 'fast', 61 + 1, 62 + 0.5, 63 + 0.5, 64 + ) 65 + await element(by.id('viewHeaderHomeFeedPrefsBtn')).tap() 66 + await element(by.id('toggleRepliesBtn')).tap() 67 + await element(by.id('confirmBtn')).tap() 68 + await element(by.id('followingFeedPage-feed-flatlist')).swipe( 69 + 'down', 70 + 'slow', 71 + 1, 72 + 0.5, 73 + 0.5, 74 + ) 75 + 76 + // followed users 77 + await expect( 78 + element( 79 + by.id('postText').withAncestor(by.id('feedItem-by-carla.test')), 80 + ).atIndex(0), 81 + ).toHaveText('Post 9') 82 + await expect( 83 + element( 84 + by.id('postText').withAncestor(by.id('feedItem-by-bob.test')), 85 + ).atIndex(0), 86 + ).toHaveText('Post 9') 87 + await element(by.id('followingFeedPage-feed-flatlist')).swipe( 88 + 'up', 89 + 'fast', 90 + 1, 91 + 0.5, 92 + 0.5, 93 + ) 94 + 95 + // feed users 96 + await expect( 97 + element( 98 + by.id('postText').withAncestor(by.id('feedItem-by-dan.test')), 99 + ).atIndex(0), 100 + ).toHaveText('Post 0') 101 + }) 102 + 103 + it('Sees the expected mix of posts with no follows', async () => { 104 + await element(by.id('followingFeedPage-feed-flatlist')).swipe( 105 + 'down', 106 + 'fast', 107 + 1, 108 + 0.5, 109 + 0.5, 110 + ) 111 + 112 + await element(by.id('bottomBarSearchBtn')).tap() 113 + await element(by.id('searchTextInput')).typeText('bob') 114 + await element(by.id('searchAutoCompleteResult-bob.test')).tap() 115 + await expect(element(by.id('profileView'))).toBeVisible() 116 + await element(by.id('unfollowBtn')).tap() 117 + await element(by.id('profileHeaderBackBtn')).tap() 118 + 119 + // have to wait for the toast to clear 120 + await waitFor(element(by.id('searchTextInputClearBtn'))) 121 + .toBeVisible() 122 + .withTimeout(5000) 123 + await element(by.id('searchTextInputClearBtn')).tap() 124 + await element(by.id('searchTextInput')).typeText('carla') 125 + await element(by.id('searchAutoCompleteResult-carla.test')).tap() 126 + await expect(element(by.id('profileView'))).toBeVisible() 127 + await element(by.id('unfollowBtn')).tap() 128 + await element(by.id('profileHeaderBackBtn')).tap() 129 + 130 + await element(by.id('bottomBarHomeBtn')).tap() 131 + await element(by.id('followingFeedPage-feed-flatlist')).swipe( 132 + 'down', 133 + 'slow', 134 + 1, 135 + 0.5, 136 + 0.5, 137 + ) 138 + await element(by.id('followingFeedPage-feed-flatlist')).swipe( 139 + 'down', 140 + 'slow', 141 + 1, 142 + 0.5, 143 + 0.5, 144 + ) 145 + 146 + // followed users NOT present 147 + await expect(element(by.id('feedItem-by-carla.test'))).not.toExist() 148 + await expect(element(by.id('feedItem-by-bob.test'))).not.toExist() 149 + 150 + // feed users 151 + await expect( 152 + element( 153 + by.id('postText').withAncestor(by.id('feedItem-by-dan.test')), 154 + ).atIndex(0), 155 + ).toHaveText('Post 0') 156 + }) 157 + })
+6 -19
__e2e__/tests/mute-lists.test.ts
··· 1 1 /* eslint-env detox/detox */ 2 2 3 - import {openApp, login, createServer, sleep} from '../util' 3 + import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util' 4 4 5 5 describe('Mute lists', () => { 6 - let service: string 7 6 beforeAll(async () => { 8 - service = await createServer('?users&follows&labels') 7 + await createServer('?users&follows&labels') 9 8 await openApp({ 10 9 permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, 11 10 }) ··· 13 12 14 13 it('Login and view my mutelists', async () => { 15 14 await expect(element(by.id('signInButton'))).toBeVisible() 16 - await login(service, 'alice', 'hunter2') 17 - await element(by.id('viewHeaderDrawerBtn')).tap() 18 - await expect(element(by.id('drawer'))).toBeVisible() 19 - await element(by.id('menuItemButton-Moderation')).tap() 15 + await loginAsAlice() 16 + await element(by.id('e2eGotoModeration')).tap() 20 17 await element(by.id('mutelistsBtn')).tap() 21 18 await expect(element(by.id('list-Muted Users'))).toBeVisible() 22 19 await element(by.id('list-Muted Users')).tap() ··· 141 138 }) 142 139 143 140 it('Can report a mute list', async () => { 144 - await element(by.id('bottomBarHomeBtn')).tap() 145 - // Last test leaves us in the list view so we are going back 1 screen to the lists list screen 146 - await element(by.id('viewHeaderDrawerBtn')).tap() 147 - // then to the moderation screen 148 - await element(by.id('viewHeaderDrawerBtn')).tap() 149 - // then to the home screen 150 - await element(by.id('viewHeaderDrawerBtn')).tap() 151 - // then open the drawer to go to settings 152 - await element(by.id('viewHeaderDrawerBtn')).tap() 153 - await element(by.id('menuItemButton-Settings')).tap() 141 + await element(by.id('e2eGotoSettings')).tap() 154 142 await element(by.id('signOutBtn')).tap() 155 - await expect(element(by.id('signInButton'))).toBeVisible() 156 - await login(service, 'bob.test', 'hunter2') 143 + await loginAsBob() 157 144 await element(by.id('bottomBarSearchBtn')).tap() 158 145 await element(by.id('searchTextInput')).typeText('alice') 159 146 await element(by.id('searchAutoCompleteResult-alice.test')).tap()
+3 -4
__e2e__/tests/profile-screen.test.ts
··· 1 1 /* eslint-env detox/detox */ 2 2 3 - import {openApp, login, createServer, sleep} from '../util' 3 + import {openApp, loginAsAlice, createServer, sleep} from '../util' 4 4 5 5 describe('Profile screen', () => { 6 - let service: string 7 6 beforeAll(async () => { 8 - service = await createServer('?users&posts&feeds') 7 + await createServer('?users&posts&feeds') 9 8 await openApp({ 10 9 permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, 11 10 }) ··· 13 12 14 13 it('Login and navigate to my profile', async () => { 15 14 await expect(element(by.id('signInButton'))).toBeVisible() 16 - await login(service, 'alice', 'hunter2') 15 + await loginAsAlice() 17 16 await element(by.id('bottomBarProfileBtn')).tap() 18 17 }) 19 18
+3 -4
__e2e__/tests/search-screen.test.ts
··· 1 1 /* eslint-env detox/detox */ 2 2 3 - import {openApp, login, createServer} from '../util' 3 + import {openApp, loginAsAlice, createServer} from '../util' 4 4 5 5 describe('Search screen', () => { 6 - let service: string 7 6 beforeAll(async () => { 8 - service = await createServer('?users') 7 + await createServer('?users') 9 8 await openApp({ 10 9 permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, 11 10 }) 12 11 }) 13 12 14 13 it('Login', async () => { 15 - await login(service, 'alice', 'hunter2') 14 + await loginAsAlice() 16 15 }) 17 16 18 17 it('Navigate to another user profile via autocomplete', async () => {
+3 -4
__e2e__/tests/self-labeling.test.ts
··· 1 1 /* eslint-env detox/detox */ 2 2 3 - import {openApp, login, createServer, sleep} from '../util' 3 + import {openApp, loginAsAlice, createServer, sleep} from '../util' 4 4 5 5 describe('Self-labeling', () => { 6 - let service: string 7 6 beforeAll(async () => { 8 - service = await createServer('?users') 7 + await createServer('?users') 9 8 await openApp({ 10 9 permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'}, 11 10 }) 12 11 }) 13 12 14 13 it('Login', async () => { 15 - await login(service, 'alice', 'hunter2') 14 + await loginAsAlice() 16 15 await element(by.id('homeScreenFeedTabs-Following')).tap() 17 16 }) 18 17
+3 -4
__e2e__/tests/shell.test.ts
··· 1 1 /* eslint-env detox/detox */ 2 2 3 - import {openApp, login, createServer} from '../util' 3 + import {openApp, loginAsAlice, createServer} from '../util' 4 4 5 5 describe('Shell', () => { 6 - let service: string 7 6 beforeAll(async () => { 8 - service = await createServer('?users') 7 + await createServer('?users') 9 8 await openApp({permissions: {notifications: 'YES'}}) 10 9 }) 11 10 12 11 it('Login', async () => { 13 - await login(service, 'alice', 'hunter2') 12 + await loginAsAlice() 14 13 await element(by.id('homeScreenFeedTabs-Following')).tap() 15 14 }) 16 15
+8 -23
__e2e__/tests/thread-muting.test.ts
··· 1 1 /* eslint-env detox/detox */ 2 2 3 - import {openApp, login, createServer} from '../util' 3 + import {openApp, loginAsAlice, loginAsBob, createServer} from '../util' 4 4 5 5 describe('Thread muting', () => { 6 - let service: string 7 6 beforeAll(async () => { 8 - service = await createServer('?users&follows') 7 + await createServer('?users&follows') 9 8 await openApp({permissions: {notifications: 'YES'}}) 10 9 }) 11 10 12 11 it('Login, create a thread, and log out', async () => { 13 - await login(service, 'alice', 'hunter2') 12 + await loginAsAlice() 14 13 await element(by.id('homeScreenFeedTabs-Following')).tap() 15 14 await element(by.id('composeFAB')).tap() 16 15 await element(by.id('composerTextInput')).typeText('Test thread') 17 16 await element(by.id('composerPublishBtn')).tap() 18 17 await expect(element(by.id('composeFAB'))).toBeVisible() 19 - await element(by.id('viewHeaderDrawerBtn')).tap() 20 - await element(by.id('menuItemButton-Settings')).tap() 21 - await element(by.id('signOutBtn')).tap() 22 18 }) 23 19 24 20 it('Login, reply to the thread, and log out', async () => { 25 - await login(service, 'bob', 'hunter2') 21 + await loginAsBob() 26 22 await element(by.id('homeScreenFeedTabs-Following')).tap() 27 23 const alicePosts = by.id('feedItem-by-alice.test') 28 24 await element(by.id('replyBtn').withAncestor(alicePosts)).atIndex(0).tap() 29 25 await element(by.id('composerTextInput')).typeText('Reply 1') 30 26 await element(by.id('composerPublishBtn')).tap() 31 27 await expect(element(by.id('composeFAB'))).toBeVisible() 32 - await element(by.id('viewHeaderDrawerBtn')).tap() 33 - await element(by.id('menuItemButton-Settings')).tap() 34 - await element(by.id('signOutBtn')).tap() 35 28 }) 36 29 37 30 it('Login, confirm notification exists, mute thread, and log out', async () => { 38 - await login(service, 'alice', 'hunter2') 39 - 31 + await loginAsAlice() 40 32 await element(by.id('bottomBarNotificationsBtn')).tap() 41 33 const bobNotifs = by.id('feedItem-by-bob.test') 42 34 await expect( ··· 50 42 await waitFor(element(by.id('viewHeaderDrawerBtn'))) 51 43 .toBeVisible() 52 44 .withTimeout(5000) 53 - 54 - await element(by.id('viewHeaderDrawerBtn')).tap() 55 - await element(by.id('menuItemButton-Settings')).tap() 56 - await element(by.id('signOutBtn')).tap() 57 45 }) 58 46 59 47 it('Login, reply to the thread twice, and log out', async () => { 60 - await login(service, 'bob', 'hunter2') 48 + await loginAsBob() 61 49 62 50 await element(by.id('bottomBarProfileBtn')).tap() 63 51 await element(by.id('selector-1')).tap() ··· 74 62 await expect(element(by.id('composeFAB'))).toBeVisible() 75 63 76 64 await element(by.id('bottomBarHomeBtn')).tap() 77 - await element(by.id('viewHeaderDrawerBtn')).tap() 78 - await element(by.id('menuItemButton-Settings')).tap() 79 - await element(by.id('signOutBtn')).tap() 80 65 }) 81 66 82 67 it('Login, confirm notifications dont exist, unmute the thread, confirm notifications exist', async () => { 83 - await login(service, 'alice', 'hunter2') 68 + await loginAsAlice() 84 69 85 70 await element(by.id('bottomBarNotificationsBtn')).tap() 86 71 const bobNotifs = by.id('feedItem-by-bob.test') ··· 93 78 await element(by.id('postDropdownBtn').withAncestor(alicePosts)) 94 79 .atIndex(0) 95 80 .tap() 96 - await element(by.text('Mute thread')).tap() 81 + await element(by.text('Unmute thread')).tap() 97 82 98 83 // TODO 99 84 // the swipe down to trigger PTR isnt working and I dont want to block on this
+3 -4
__e2e__/tests/thread-screen.test.ts
··· 1 1 /* eslint-env detox/detox */ 2 2 3 - import {openApp, login, createServer} from '../util' 3 + import {openApp, loginAsAlice, createServer} from '../util' 4 4 5 5 describe('Thread screen', () => { 6 - let service: string 7 6 beforeAll(async () => { 8 - service = await createServer('?users&follows&thread') 7 + await createServer('?users&follows&thread') 9 8 await openApp({permissions: {notifications: 'YES'}}) 10 9 }) 11 10 12 11 it('Login & navigate to thread', async () => { 13 - await login(service, 'alice', 'hunter2') 12 + await loginAsAlice() 14 13 await element(by.id('homeScreenFeedTabs-Following')).tap() 15 14 await element(by.id('feedItem-by-bob.test')).atIndex(0).tap() 16 15 await expect(
+8
__e2e__/util.ts
··· 69 69 await element(by.id('loginNextButton')).tap() 70 70 } 71 71 72 + export async function loginAsAlice() { 73 + await element(by.id('e2eSignInAlice')).tap() 74 + } 75 + 76 + export async function loginAsBob() { 77 + await element(by.id('e2eSignInBob')).tap() 78 + } 79 + 72 80 async function openAppForDebugBuild(platform: string, opts: any) { 73 81 const deepLinkUrl = // Local testing with packager 74 82 /*process.env.EXPO_USE_UPDATES
+30 -12
jest/test-pds.ts
··· 1 1 import net from 'net' 2 2 import path from 'path' 3 3 import fs from 'fs' 4 - import {TestPds as DevEnvTestPDS, TestNetworkNoAppView} from '@atproto/dev-env' 4 + import {TestNetworkNoAppView} from '@atproto/dev-env' 5 5 import {AtUri, BskyAgent} from '@atproto/api' 6 6 7 7 export interface TestUser { ··· 24 24 const port = await getPort() 25 25 const port2 = await getPort(port + 1) 26 26 const pdsUrl = `http://localhost:${port}` 27 - const {pds, plc} = await TestNetworkNoAppView.create({ 27 + const testNet = await TestNetworkNoAppView.create({ 28 28 pds: {port, publicUrl: pdsUrl, inviteRequired}, 29 29 plc: {port: port2}, 30 30 }) ··· 35 35 36 36 return { 37 37 pdsUrl, 38 - mocker: new Mocker(pds, pdsUrl, pic), 38 + mocker: new Mocker(testNet, pdsUrl, pic), 39 39 async close() { 40 - await pds.server.destroy() 41 - await plc.server.destroy() 40 + await testNet.pds.server.destroy() 41 + await testNet.plc.server.destroy() 42 42 }, 43 43 } 44 44 } ··· 48 48 users: Record<string, TestUser> = {} 49 49 50 50 constructor( 51 - public pds: DevEnvTestPDS, 51 + public testNet: TestNetworkNoAppView, 52 52 public service: string, 53 53 public pic: Uint8Array, 54 54 ) { 55 55 this.agent = new BskyAgent({service}) 56 + } 57 + 58 + get pds() { 59 + return this.testNet.pds 60 + } 61 + 62 + get plc() { 63 + return this.testNet.plc 56 64 } 57 65 58 66 // NOTE ··· 212 220 return await agent.like(uri, cid) 213 221 } 214 222 215 - async createFeed(user: string) { 223 + async createFeed(user: string, rkey: string, posts: string[]) { 216 224 const agent = this.users[user]?.agent 217 225 if (!agent) { 218 226 throw new Error(`Not a user: ${user}`) 219 227 } 220 - const fg1Uri = AtUri.make( 228 + const fgUri = AtUri.make( 221 229 this.users[user].did, 222 230 'app.bsky.feed.generator', 223 - 'alice-favs', 231 + rkey, 224 232 ) 233 + const fg1 = await this.testNet.createFeedGen({ 234 + [fgUri.toString()]: async () => { 235 + return { 236 + encoding: 'application/json', 237 + body: { 238 + feed: posts.slice(0, 30).map(uri => ({post: uri})), 239 + }, 240 + } 241 + }, 242 + }) 225 243 const avatarRes = await agent.api.com.atproto.repo.uploadBlob(this.pic, { 226 244 encoding: 'image/png', 227 245 }) 228 246 return await agent.api.app.bsky.feed.generator.create( 229 - {repo: this.users[user].did, rkey: fg1Uri.rkey}, 247 + {repo: this.users[user].did, rkey}, 230 248 { 231 - did: 'did:web:fake.com', 232 - displayName: 'alices feed', 249 + did: fg1.did, 250 + displayName: rkey, 233 251 description: 'all my fav stuff', 234 252 avatar: avatarRes.data.blob, 235 253 createdAt: new Date().toISOString(),
+2
src/App.native.tsx
··· 18 18 import {handleLink} from './Navigation' 19 19 import {QueryClientProvider} from '@tanstack/react-query' 20 20 import {queryClient} from 'lib/react-query' 21 + import {TestCtrls} from 'view/com/testing/TestCtrls' 21 22 22 23 SplashScreen.preventAutoHideAsync() 23 24 ··· 59 60 <analytics.Provider> 60 61 <RootStoreProvider value={rootStore}> 61 62 <GestureHandlerRootView style={s.h100pct}> 63 + <TestCtrls /> 62 64 <Shell /> 63 65 </GestureHandlerRootView> 64 66 </RootStoreProvider>
+20 -11
src/lib/api/feed-manip.ts
··· 128 128 tune( 129 129 feed: FeedViewPost[], 130 130 tunerFns: FeedTunerFn[] = [], 131 - {dryRun}: {dryRun: boolean} = {dryRun: false}, 131 + {dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = { 132 + dryRun: false, 133 + maintainOrder: false, 134 + }, 132 135 ): FeedViewPostsSlice[] { 133 136 let slices: FeedViewPostsSlice[] = [] 134 137 135 - // arrange the posts into thread slices 136 - for (let i = feed.length - 1; i >= 0; i--) { 137 - const item = feed[i] 138 + if (maintainOrder) { 139 + slices = feed.map(item => new FeedViewPostsSlice([item])) 140 + } else { 141 + // arrange the posts into thread slices 142 + for (let i = feed.length - 1; i >= 0; i--) { 143 + const item = feed[i] 138 144 139 - const selfReplyUri = getSelfReplyUri(item) 140 - if (selfReplyUri) { 141 - const parent = slices.find(item2 => item2.isNextInThread(selfReplyUri)) 142 - if (parent) { 143 - parent.insert(item) 144 - continue 145 + const selfReplyUri = getSelfReplyUri(item) 146 + if (selfReplyUri) { 147 + const parent = slices.find(item2 => 148 + item2.isNextInThread(selfReplyUri), 149 + ) 150 + if (parent) { 151 + parent.insert(item) 152 + continue 153 + } 145 154 } 155 + slices.unshift(new FeedViewPostsSlice([item])) 146 156 } 147 - slices.unshift(new FeedViewPostsSlice([item])) 148 157 } 149 158 150 159 // run the custom tuners
+25 -6
src/lib/api/feed/merge.ts
··· 4 4 import {timeout} from 'lib/async/timeout' 5 5 import {bundleAsync} from 'lib/async/bundle' 6 6 import {feedUriToHref} from 'lib/strings/url-helpers' 7 + import {FeedTuner} from '../feed-manip' 7 8 import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types' 8 9 9 10 const REQUEST_WAIT_MS = 500 // 500ms ··· 43 44 44 45 // always keep following topped up 45 46 if (this.following.numReady < limit) { 46 - promises.push(this.following.fetchNext(30)) 47 + promises.push(this.following.fetchNext(60)) 47 48 } 48 49 49 50 // pick the next feeds to sample from ··· 84 85 const i = this.itemCursor++ 85 86 const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0) 86 87 const canSample = candidateFeeds.length > 0 87 - const hasFollows = this.following.numReady > 0 88 + const hasFollows = this.following.hasMore 89 + const hasFollowsReady = this.following.numReady > 0 88 90 89 91 // this condition establishes the frequency that custom feeds are woven into follows 90 92 const shouldSample = ··· 98 100 // time to sample, or the user isnt following anybody 99 101 return candidateFeeds[this.sampleCursor++ % candidateFeeds.length].take(1) 100 102 } 101 - // not time to sample 103 + if (!hasFollowsReady) { 104 + // stop here so more follows can be fetched 105 + return [] 106 + } 107 + // provide follow 102 108 return this.following.take(1) 103 109 } 104 110 ··· 174 180 } 175 181 176 182 class MergeFeedSource_Following extends MergeFeedSource { 183 + tuner = new FeedTuner() 184 + 185 + reset() { 186 + super.reset() 187 + this.tuner.reset() 188 + } 189 + 177 190 async fetchNext(n: number) { 178 191 return this._fetchNextInner(n) 179 192 } ··· 183 196 limit: number, 184 197 ): Promise<AppBskyFeedGetTimeline.Response> { 185 198 const res = await this.rootStore.agent.getTimeline({cursor, limit}) 186 - // filter out mutes pre-emptively to ensure better mixing 187 - res.data.feed = res.data.feed.filter( 188 - post => !post.post.author.viewer?.muted, 199 + // run the tuner pre-emptively to ensure better mixing 200 + const slices = this.tuner.tune( 201 + res.data.feed, 202 + this.rootStore.preferences.getFeedTuners('home'), 203 + { 204 + dryRun: false, 205 + maintainOrder: true, 206 + }, 189 207 ) 208 + res.data.feed = slices.map(slice => slice.rootItem) 190 209 return res 191 210 } 192 211 }
+8 -2
src/lib/constants.ts
··· 83 83 // local dev 84 84 const aliceDid = await resolveHandle('alice.test') 85 85 return { 86 - pinned: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], 87 - saved: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], 86 + pinned: [ 87 + `at://${aliceDid}/app.bsky.feed.generator/alice-favs`, 88 + `at://${aliceDid}/app.bsky.feed.generator/alice-favs2`, 89 + ], 90 + saved: [ 91 + `at://${aliceDid}/app.bsky.feed.generator/alice-favs`, 92 + `at://${aliceDid}/app.bsky.feed.generator/alice-favs2`, 93 + ], 88 94 } 89 95 } else if (IS_STAGING(serviceUrl)) { 90 96 // staging
+12 -51
src/state/models/feeds/posts.ts
··· 139 139 this.tuner.reset() 140 140 } 141 141 142 - get feedTuners() { 143 - const areRepliesEnabled = this.rootStore.preferences.homeFeedRepliesEnabled 144 - const areRepliesByFollowedOnlyEnabled = 145 - this.rootStore.preferences.homeFeedRepliesByFollowedOnlyEnabled 146 - const repliesThreshold = this.rootStore.preferences.homeFeedRepliesThreshold 147 - const areRepostsEnabled = this.rootStore.preferences.homeFeedRepostsEnabled 148 - const areQuotePostsEnabled = 149 - this.rootStore.preferences.homeFeedQuotePostsEnabled 150 - 151 - if (this.feedType === 'custom') { 152 - return [ 153 - FeedTuner.dedupReposts, 154 - FeedTuner.preferredLangOnly( 155 - this.rootStore.preferences.contentLanguages, 156 - ), 157 - ] 158 - } 159 - if (this.feedType === 'home' || this.feedType === 'following') { 160 - const feedTuners = [] 161 - 162 - if (areRepostsEnabled) { 163 - feedTuners.push(FeedTuner.dedupReposts) 164 - } else { 165 - feedTuners.push(FeedTuner.removeReposts) 166 - } 167 - 168 - if (areRepliesEnabled) { 169 - feedTuners.push( 170 - FeedTuner.thresholdRepliesOnly({ 171 - userDid: this.rootStore.session.data?.did || '', 172 - minLikes: repliesThreshold, 173 - followedOnly: areRepliesByFollowedOnlyEnabled, 174 - }), 175 - ) 176 - } else { 177 - feedTuners.push(FeedTuner.removeReplies) 178 - } 179 - 180 - if (!areQuotePostsEnabled) { 181 - feedTuners.push(FeedTuner.removeQuotePosts) 182 - } 183 - 184 - return feedTuners 185 - } 186 - return [] 187 - } 188 - 189 142 /** 190 143 * Load for first render 191 144 */ ··· 275 228 } 276 229 const post = await this.api.peekLatest() 277 230 if (post) { 278 - const slices = this.tuner.tune([post], this.feedTuners, { 279 - dryRun: true, 280 - }) 231 + const slices = this.tuner.tune( 232 + [post], 233 + this.rootStore.preferences.getFeedTuners(this.feedType), 234 + { 235 + dryRun: true, 236 + maintainOrder: true, 237 + }, 238 + ) 281 239 if (slices[0]) { 282 240 const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0]) 283 241 if (sliceModel.moderation.content.filter) { ··· 363 321 364 322 const slices = this.options.isSimpleFeed 365 323 ? res.feed.map(item => new FeedViewPostsSlice([item])) 366 - : this.tuner.tune(res.feed, this.feedTuners) 324 + : this.tuner.tune( 325 + res.feed, 326 + this.rootStore.preferences.getFeedTuners(this.feedType), 327 + ) 367 328 368 329 const toAppend: PostsFeedSliceModel[] = [] 369 330 for (const slice of slices) {
+47
src/state/models/ui/preferences.ts
··· 8 8 import {DEFAULT_FEEDS} from 'lib/constants' 9 9 import {deviceLocales} from 'platform/detection' 10 10 import {getAge} from 'lib/strings/time' 11 + import {FeedTuner} from 'lib/api/feed-manip' 11 12 import {LANGUAGES} from '../../../locale/languages' 12 13 13 14 // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf ··· 539 540 540 541 toggleRequireAltTextEnabled() { 541 542 this.requireAltTextEnabled = !this.requireAltTextEnabled 543 + } 544 + 545 + getFeedTuners( 546 + feedType: 'home' | 'following' | 'author' | 'custom' | 'likes', 547 + ) { 548 + const areRepliesEnabled = this.homeFeedRepliesEnabled 549 + const areRepliesByFollowedOnlyEnabled = 550 + this.homeFeedRepliesByFollowedOnlyEnabled 551 + const repliesThreshold = this.homeFeedRepliesThreshold 552 + const areRepostsEnabled = this.homeFeedRepostsEnabled 553 + const areQuotePostsEnabled = this.homeFeedQuotePostsEnabled 554 + 555 + if (feedType === 'custom') { 556 + return [ 557 + FeedTuner.dedupReposts, 558 + FeedTuner.preferredLangOnly(this.contentLanguages), 559 + ] 560 + } 561 + if (feedType === 'home' || feedType === 'following') { 562 + const feedTuners = [] 563 + 564 + if (areRepostsEnabled) { 565 + feedTuners.push(FeedTuner.dedupReposts) 566 + } else { 567 + feedTuners.push(FeedTuner.removeReposts) 568 + } 569 + 570 + if (areRepliesEnabled) { 571 + feedTuners.push( 572 + FeedTuner.thresholdRepliesOnly({ 573 + userDid: this.rootStore.session.data?.did || '', 574 + minLikes: repliesThreshold, 575 + followedOnly: areRepliesByFollowedOnlyEnabled, 576 + }), 577 + ) 578 + } else { 579 + feedTuners.push(FeedTuner.removeReplies) 580 + } 581 + 582 + if (!areQuotePostsEnabled) { 583 + feedTuners.push(FeedTuner.removeQuotePosts) 584 + } 585 + 586 + return feedTuners 587 + } 588 + return [] 542 589 } 543 590 } 544 591
+1 -1
src/view/com/modals/ProfilePreview.tsx
··· 35 35 }, [model, screen]) 36 36 37 37 return ( 38 - <View style={[pal.view, s.flex1]}> 38 + <View testID="profilePreview" style={[pal.view, s.flex1]}> 39 39 <View 40 40 style={[ 41 41 styles.headerWrapper,
+1
src/view/com/pager/FeedsTabBarMobile.tsx
··· 67 67 </Text> 68 68 <View style={[pal.view]}> 69 69 <Link 70 + testID="viewHeaderHomeFeedPrefsBtn" 70 71 href="/settings/home-feed" 71 72 hitSlop={HITSLOP_10} 72 73 accessibilityRole="button"
+1
src/view/com/posts/FeedItem.tsx
··· 299 299 {item.richText?.text ? ( 300 300 <View style={styles.postTextContainer}> 301 301 <RichText 302 + testID="postText" 302 303 type="post-text" 303 304 richText={item.richText} 304 305 lineHeight={1.3}
+1
src/view/com/profile/ProfileHeader.tsx
··· 556 556 557 557 {!isDesktop && !hideBackButton && ( 558 558 <TouchableWithoutFeedback 559 + testID="profileHeaderBackBtn" 559 560 onPress={onPressBack} 560 561 hitSlop={BACK_HITSLOP} 561 562 accessibilityRole="button"
+1
src/view/com/search/HeaderWithInput.tsx
··· 102 102 /> 103 103 {query ? ( 104 104 <TouchableOpacity 105 + testID="searchTextInputClearBtn" 105 106 onPress={onPressClearQuery} 106 107 accessibilityRole="button" 107 108 accessibilityLabel="Clear search query"
+76
src/view/com/testing/TestCtrls.e2e.tsx
··· 1 + import React from 'react' 2 + import {Pressable, View} from 'react-native' 3 + import {useStores} from 'state/index' 4 + import {navigate} from '../../../Navigation' 5 + 6 + /** 7 + * This utility component is only included in the test simulator 8 + * build. It gives some quick triggers which help improve the pace 9 + * of the tests dramatically. 10 + */ 11 + 12 + const BTN = {height: 1, width: 1, backgroundColor: 'red'} 13 + 14 + export function TestCtrls() { 15 + const store = useStores() 16 + const onPressSignInAlice = async () => { 17 + await store.session.login({ 18 + service: 'http://localhost:3000', 19 + identifier: 'alice.test', 20 + password: 'hunter2', 21 + }) 22 + } 23 + const onPressSignInBob = async () => { 24 + await store.session.login({ 25 + service: 'http://localhost:3000', 26 + identifier: 'bob.test', 27 + password: 'hunter2', 28 + }) 29 + } 30 + return ( 31 + <View style={{position: 'absolute', top: 100, right: 0, zIndex: 100}}> 32 + <Pressable 33 + testID="e2eSignInAlice" 34 + onPress={onPressSignInAlice} 35 + accessibilityRole="button" 36 + style={BTN} 37 + /> 38 + <Pressable 39 + testID="e2eSignInBob" 40 + onPress={onPressSignInBob} 41 + accessibilityRole="button" 42 + style={BTN} 43 + /> 44 + <Pressable 45 + testID="e2eGotoHome" 46 + onPress={() => navigate('Home')} 47 + accessibilityRole="button" 48 + style={BTN} 49 + /> 50 + <Pressable 51 + testID="e2eGotoSettings" 52 + onPress={() => navigate('Settings')} 53 + accessibilityRole="button" 54 + style={BTN} 55 + /> 56 + <Pressable 57 + testID="e2eGotoModeration" 58 + onPress={() => navigate('Moderation')} 59 + accessibilityRole="button" 60 + style={BTN} 61 + /> 62 + <Pressable 63 + testID="e2eToggleMergefeed" 64 + onPress={() => store.preferences.toggleHomeFeedMergeFeedEnabled()} 65 + accessibilityRole="button" 66 + style={BTN} 67 + /> 68 + <Pressable 69 + testID="e2eRefreshHome" 70 + onPress={() => store.me.mainFeed.refresh()} 71 + accessibilityRole="button" 72 + style={BTN} 73 + /> 74 + </View> 75 + ) 76 + }
+3
src/view/com/testing/TestCtrls.tsx
··· 1 + export function TestCtrls() { 2 + return null 3 + }
+3 -1
src/view/com/util/forms/ToggleButton.tsx
··· 8 8 import {TypographyVariant} from 'lib/ThemeContext' 9 9 10 10 export function ToggleButton({ 11 + testID, 11 12 type = 'default-light', 12 13 label, 13 14 isSelected, ··· 15 16 labelType, 16 17 onPress, 17 18 }: { 19 + testID?: string 18 20 type?: ButtonType 19 21 label: string 20 22 isSelected: boolean ··· 134 136 }, 135 137 }) 136 138 return ( 137 - <Button type={type} onPress={onPress} style={style}> 139 + <Button testID={testID} type={type} onPress={onPress} style={style}> 138 140 <View style={styles.outer}> 139 141 <View style={[circleStyle, styles.circle]}> 140 142 <View
+1
src/view/screens/PreferencesHomeFeed.tsx
··· 86 86 Set this setting to "No" to hide all replies from your feed. 87 87 </Text> 88 88 <ToggleButton 89 + testID="toggleRepliesBtn" 89 90 type="default-light" 90 91 label={store.preferences.homeFeedRepliesEnabled ? 'Yes' : 'No'} 91 92 isSelected={store.preferences.homeFeedRepliesEnabled}