about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.detoxrc.js2
-rw-r--r--__e2e__/mock-server.ts78
-rw-r--r--__e2e__/tests/composer.test.ts7
-rw-r--r--__e2e__/tests/home-screen.test.ts7
-rw-r--r--__e2e__/tests/invite-codes.test-skip.ts (renamed from __e2e__/tests/invite-codes.test.ts)16
-rw-r--r--__e2e__/tests/merge-feed.test.ts157
-rw-r--r--__e2e__/tests/mute-lists.test.ts25
-rw-r--r--__e2e__/tests/profile-screen.test.ts7
-rw-r--r--__e2e__/tests/search-screen.test.ts7
-rw-r--r--__e2e__/tests/self-labeling.test.ts7
-rw-r--r--__e2e__/tests/shell.test.ts7
-rw-r--r--__e2e__/tests/thread-muting.test.ts31
-rw-r--r--__e2e__/tests/thread-screen.test.ts7
-rw-r--r--__e2e__/util.ts8
-rw-r--r--jest/test-pds.ts42
-rw-r--r--src/App.native.tsx2
-rw-r--r--src/lib/api/feed-manip.ts33
-rw-r--r--src/lib/api/feed/merge.ts31
-rw-r--r--src/lib/constants.ts10
-rw-r--r--src/state/models/feeds/posts.ts63
-rw-r--r--src/state/models/ui/preferences.ts47
-rw-r--r--src/view/com/modals/ProfilePreview.tsx2
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx1
-rw-r--r--src/view/com/posts/FeedItem.tsx1
-rw-r--r--src/view/com/profile/ProfileHeader.tsx1
-rw-r--r--src/view/com/search/HeaderWithInput.tsx1
-rw-r--r--src/view/com/testing/TestCtrls.e2e.tsx76
-rw-r--r--src/view/com/testing/TestCtrls.tsx3
-rw-r--r--src/view/com/util/forms/ToggleButton.tsx4
-rw-r--r--src/view/screens/PreferencesHomeFeed.tsx1
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}