about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.detoxrc.js2
-rw-r--r--.gitignore5
-rw-r--r--README.md4
-rw-r--r--__e2e__/maestro/scroll.yaml77
-rw-r--r--__e2e__/mock-server.ts3
-rw-r--r--__e2e__/tests/shell.test.skip.ts (renamed from __e2e__/tests/shell.test.ts)0
-rw-r--r--__mocks__/sentry-expo.js10
-rw-r--r--__tests__/lib/strings/url-helpers.test.ts36
-rw-r--r--app.config.js4
-rw-r--r--babel.config.js20
-rw-r--r--bskyweb/cmd/bskyweb/server.go24
-rw-r--r--docs/testing.md14
-rwxr-xr-xjest/dev-infra/_common.sh92
-rw-r--r--jest/dev-infra/docker-compose.yaml49
-rwxr-xr-xjest/dev-infra/with-test-db.sh9
-rwxr-xr-xjest/dev-infra/with-test-redis-and-db.sh10
-rw-r--r--jest/jestSetup.js11
-rw-r--r--jest/test-pds.ts154
-rw-r--r--metro.config.js18
-rw-r--r--package.json22
-rw-r--r--patches/@sentry+react-native+5.10.0.patch (renamed from patches/@sentry+react-native+5.5.0.patch)3
-rw-r--r--patches/react-native-pager-view+6.1.4.patch8
-rw-r--r--src/App.native.tsx11
-rw-r--r--src/Navigation.tsx103
-rw-r--r--src/lib/analytics/analytics.tsx6
-rw-r--r--src/lib/analytics/analytics.web.tsx6
-rw-r--r--src/lib/hooks/useMinimalShellMode.tsx72
-rw-r--r--src/lib/strings/url-helpers.ts27
-rw-r--r--src/state/models/cache/my-follows.ts5
-rw-r--r--src/state/models/feeds/post.ts14
-rw-r--r--src/state/models/me.ts8
-rw-r--r--src/state/models/media/image.ts2
-rw-r--r--src/state/models/ui/reminders.e2e.ts24
-rw-r--r--src/state/models/ui/reminders.ts12
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx23
-rw-r--r--src/view/com/feeds/FeedPage.tsx210
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx59
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx265
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx5
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts19
-rw-r--r--src/view/com/lightbox/ImageViewing/index.tsx235
-rw-r--r--src/view/com/lightbox/Lightbox.tsx4
-rw-r--r--src/view/com/modals/ChangeEmail.tsx244
-rw-r--r--src/view/com/modals/CreateOrEditMuteList.tsx4
-rw-r--r--src/view/com/modals/EditProfile.tsx4
-rw-r--r--src/view/com/modals/InviteCodes.tsx27
-rw-r--r--src/view/com/modals/Modal.tsx20
-rw-r--r--src/view/com/modals/ProfilePreview.tsx11
-rw-r--r--src/view/com/modals/VerifyEmail.tsx281
-rw-r--r--src/view/com/modals/Waitlist.tsx2
-rw-r--r--src/view/com/modals/crop-image/CropImage.web.tsx6
-rw-r--r--src/view/com/pager/FeedsTabBar.web.tsx28
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx42
-rw-r--r--src/view/com/pager/TabBar.tsx1
-rw-r--r--src/view/com/posts/Feed.tsx11
-rw-r--r--src/view/com/profile/ProfileHeader.tsx15
-rw-r--r--src/view/com/profile/ProfileHeaderSuggestedFollows.tsx16
-rw-r--r--src/view/com/util/Link.tsx9
-rw-r--r--src/view/com/util/UserAvatar.tsx108
-rw-r--r--src/view/com/util/ViewHeader.tsx31
-rw-r--r--src/view/com/util/ViewSelector.tsx15
-rw-r--r--src/view/com/util/fab/FABInner.tsx48
-rw-r--r--src/view/com/util/images/Gallery.tsx17
-rw-r--r--src/view/com/util/images/ImageLayoutGrid.tsx118
-rw-r--r--src/view/com/util/load-latest/LoadLatestBtn.tsx34
-rw-r--r--src/view/screens/Home.tsx220
-rw-r--r--src/view/screens/Notifications.tsx1
-rw-r--r--src/view/screens/Settings.tsx70
-rw-r--r--src/view/screens/Support.tsx10
-rw-r--r--src/view/shell/Drawer.tsx54
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx9
-rw-r--r--src/view/shell/bottom-bar/BottomBarStyles.tsx3
-rw-r--r--src/view/shell/bottom-bar/BottomBarWeb.tsx2
-rw-r--r--src/view/shell/desktop/RightNav.tsx72
-rw-r--r--src/view/shell/desktop/Search.tsx7
-rw-r--r--src/view/shell/index.tsx7
-rw-r--r--yarn.lock193
77 files changed, 1891 insertions, 1534 deletions
diff --git a/.detoxrc.js b/.detoxrc.js
index 1e41165da..906620430 100644
--- a/.detoxrc.js
+++ b/.detoxrc.js
@@ -41,7 +41,7 @@ module.exports = {
     simulator: {
       type: 'ios.simulator',
       device: {
-        type: 'iPhone 15',
+        type: 'iPhone 15 Pro',
       },
     },
     attached: {
diff --git a/.gitignore b/.gitignore
index 66658f8e4..0bd7d137f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -99,4 +99,7 @@ ios/
 .env.*
 
 # Firebase (Android) Google services
-google-services.json
\ No newline at end of file
+google-services.json
+
+# Performance results (Flashlight)
+.perf/
\ No newline at end of file
diff --git a/README.md b/README.md
index 08e7aba28..4f7d00ebb 100644
--- a/README.md
+++ b/README.md
@@ -12,9 +12,9 @@ Get the app itself:
 
 This is a [React Native](https://reactnative.dev/) application, written in the TypeScript programming language. It builds on the `atproto` TypeScript packages (like [`@atproto/api`](https://www.npmjs.com/package/@atproto/api)), code for which is also on open source, but in [a different git repository](https://github.com/bluesky-social/atproto).
 
-There is a small about of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application.
+There is a small amount of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application.
 
-The [Build Instructions](./docs/builds.md) are a good place to get started with the app itself.
+The [Build Instructions](./docs/build.md) are a good place to get started with the app itself.
 
 The Authenticated Transfer Protocol ("AT Protocol" or "atproto") is a decentralized social media protocol. You don't *need* to understand AT Protocol to work with this application, but it can help. Learn more at:
 
diff --git a/__e2e__/maestro/scroll.yaml b/__e2e__/maestro/scroll.yaml
new file mode 100644
index 000000000..2d32793eb
--- /dev/null
+++ b/__e2e__/maestro/scroll.yaml
@@ -0,0 +1,77 @@
+# flow.yaml
+
+appId: xyz.blueskyweb.app
+---
+- launchApp
+# Login
+# - runFlow:
+#    when:
+# - tapOn: "Sign In"
+# - tapOn: "Username or email address"
+# - inputText: "ansh.bsky.team"
+# - tapOn: "Password"
+# - inputText: "PASSWORd"
+# - tapOn: "Next"
+# Allow notifications if popup is visible
+# - runFlow:
+#     when:
+#       visible: "Notifications"
+#     commands:
+#       - tapOn: "Allow"
+# Scroll in main feed
+- "scroll"
+- "scroll"
+- "scroll"
+- "scroll"
+- "scroll"
+- "scroll"
+- "scroll"
+- "scroll"
+# Swipe between feeds
+- swipe:
+    direction: "LEFT"
+- swipe:
+    direction: "LEFT"
+- swipe: 
+    direction: "LEFT"
+- swipe:
+    direction: "RIGHT"
+- swipe:
+    direction: "RIGHT"
+- swipe:
+    direction: "RIGHT"
+# Go to Notifications
+- tapOn:
+    id: "viewHeaderDrawerBtn"
+- tapOn: "Notifications"
+- "scroll"
+- "scroll"
+- "scroll"
+- "scroll"
+- "scroll"
+- swipe:
+    direction: "DOWN" # Make header visible
+# Go to Feeds tab
+- tapOn:
+    id: "viewHeaderDrawerBtn"
+- tapOn: "Feeds"
+- scrollUntilVisible:
+    element: "Discover"
+    direction: UP
+- tapOn: "Discover"
+- waitForAnimationToEnd
+- "scroll"
+- "scroll"
+- "scroll"
+- "scroll"
+- "scroll"
+# Click on post
+- tapOn:
+    id: "postText"
+    index: 0
+- "scroll"
+- "scroll"
+- "scroll"
+- "scroll"
+- "scroll"
+
diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts
index 6613f54d0..482df6ef6 100644
--- a/__e2e__/mock-server.ts
+++ b/__e2e__/mock-server.ts
@@ -502,6 +502,9 @@ async function main() {
               createdAt: new Date().toISOString(),
             },
           )
+
+          // flush caches
+          await server.mocker.testNet.processAll()
         }
       }
       console.log('Ready')
diff --git a/__e2e__/tests/shell.test.ts b/__e2e__/tests/shell.test.skip.ts
index 69619dd81..69619dd81 100644
--- a/__e2e__/tests/shell.test.ts
+++ b/__e2e__/tests/shell.test.skip.ts
diff --git a/__mocks__/sentry-expo.js b/__mocks__/sentry-expo.js
new file mode 100644
index 000000000..e735c48c5
--- /dev/null
+++ b/__mocks__/sentry-expo.js
@@ -0,0 +1,10 @@
+jest.mock('sentry-expo', () => ({
+  init: () => jest.fn(),
+  Native: {
+    ReactNativeTracing: jest.fn().mockImplementation(() => ({
+      start: jest.fn(),
+      stop: jest.fn(),
+    })),
+    ReactNavigationInstrumentation: jest.fn(),
+  },
+}))
diff --git a/__tests__/lib/strings/url-helpers.test.ts b/__tests__/lib/strings/url-helpers.test.ts
index 3055a9ef6..8bb52ed40 100644
--- a/__tests__/lib/strings/url-helpers.test.ts
+++ b/__tests__/lib/strings/url-helpers.test.ts
@@ -27,6 +27,42 @@ describe('linkRequiresWarning', () => {
     ['http://site.pages', 'http://site.pages.dev', true],
     ['http://site.pages.dev', 'site.pages', true],
     ['http://site.pages', 'site.pages.dev', true],
+    ['http://bsky.app/profile/bob.test/post/3kbeuduu7m22v', 'my post', false],
+    ['https://bsky.app/profile/bob.test/post/3kbeuduu7m22v', 'my post', false],
+    ['http://bsky.app/', 'bluesky', false],
+    ['https://bsky.app/', 'bluesky', false],
+    [
+      'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
+      'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
+      false,
+    ],
+    [
+      'https://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
+      'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
+      false,
+    ],
+    [
+      'http://bsky.app/',
+      'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
+      false,
+    ],
+    [
+      'https://bsky.app/',
+      'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
+      false,
+    ],
+    [
+      'http://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
+      'https://google.com',
+      true,
+    ],
+    [
+      'https://bsky.app/profile/bob.test/post/3kbeuduu7m22v',
+      'https://google.com',
+      true,
+    ],
+    ['http://bsky.app/', 'https://google.com', true],
+    ['https://bsky.app/', 'https://google.com', true],
 
     // bad uri inputs, default to true
     ['', '', true],
diff --git a/app.config.js b/app.config.js
index a1477a8ae..e5d7fdf41 100644
--- a/app.config.js
+++ b/app.config.js
@@ -6,7 +6,7 @@ module.exports = function () {
       slug: 'bluesky',
       scheme: 'bluesky',
       owner: 'blueskysocial',
-      version: '1.52.0',
+      version: '1.55.0',
       runtimeVersion: {
         policy: 'appVersion',
       },
@@ -43,7 +43,7 @@ module.exports = function () {
         backgroundColor: '#ffffff',
       },
       android: {
-        versionCode: 40,
+        versionCode: 44,
         adaptiveIcon: {
           foregroundImage: './assets/adaptive-icon.png',
           backgroundColor: '#ffffff',
diff --git a/babel.config.js b/babel.config.js
index 598e2a567..0baec0c3c 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -1,7 +1,20 @@
 module.exports = function (api) {
   api.cache(true)
+  const isTestEnv = process.env.NODE_ENV === 'test'
   return {
-    presets: ['babel-preset-expo'],
+    presets: [
+      [
+        'babel-preset-expo',
+        {
+          lazyImports: true,
+          native: {
+            // Disable ESM -> CJS compilation because Metro takes care of it.
+            // However, we need it in Jest tests since those run without Metro.
+            disableImportExportTransform: !isTestEnv,
+          },
+        },
+      ],
+    ],
     plugins: [
       [
         'module:react-native-dotenv',
@@ -30,5 +43,10 @@ module.exports = function (api) {
       ],
       'react-native-reanimated/plugin', // NOTE: this plugin MUST be last
     ],
+    env: {
+      production: {
+        plugins: ['transform-remove-console'],
+      },
+    },
   }
 }
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index d5d864069..5be96ce0e 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -91,6 +91,11 @@ func serve(cctx *cli.Context) error {
 	}
 
 	e.HideBanner = true
+	e.Renderer = NewRenderer("templates/", &bskyweb.TemplateFS, debug)
+	e.HTTPErrorHandler = server.errorHandler
+
+	e.IPExtractor = echo.ExtractIPFromXFFHeader()
+
 	// SECURITY: Do not modify without due consideration.
 	e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
 		ContentTypeNosniff: "nosniff",
@@ -106,8 +111,23 @@ func serve(cctx *cli.Context) error {
 			return strings.HasPrefix(c.Request().URL.Path, "/static")
 		},
 	}))
-	e.Renderer = NewRenderer("templates/", &bskyweb.TemplateFS, debug)
-	e.HTTPErrorHandler = server.errorHandler
+	e.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
+		Skipper: middleware.DefaultSkipper,
+		Store: middleware.NewRateLimiterMemoryStoreWithConfig(
+			middleware.RateLimiterMemoryStoreConfig{
+				Rate:      10,              // requests per second
+				Burst:     30,              // allow bursts
+				ExpiresIn: 3 * time.Minute, // garbage collect entries older than 3 minutes
+			},
+		),
+		IdentifierExtractor: func(ctx echo.Context) (string, error) {
+			id := ctx.RealIP()
+			return id, nil
+		},
+		DenyHandler: func(c echo.Context, identifier string, err error) error {
+			return c.String(http.StatusTooManyRequests, "Your request has been rate limited. Please try again later. Contact security@bsky.app if you believe this was a mistake.\n")
+		},
+	}))
 
 	// redirect trailing slash to non-trailing slash.
 	// all of our current endpoints have no trailing slash.
diff --git a/docs/testing.md b/docs/testing.md
new file mode 100644
index 000000000..8af163a8d
--- /dev/null
+++ b/docs/testing.md
@@ -0,0 +1,14 @@
+# Testing instructions
+
+### Using Maestro E2E tests
+1. Install Maestro by following [these instuctions](https://maestro.mobile.dev/getting-started/installing-maestro). This will help us run the E2E tests.
+2. You can write Maestro tests in `__e2e__/maestro` directory by creating a new `.yaml` file or by modifying an existing one.
+3. You can also use [Maestro Studio](https://maestro.mobile.dev/getting-started/maestro-studio) which automatically generates commands by recording your actions on the app. Therefore, you can create realistic tests without having to manually write any code. Use  the `maestro studio` command to start recording your actions.
+
+
+### Using Flashlight for Performance Testing
+1. Make sure Maestro is installed (optional: only for auomated testing) by following the instructions above
+2. Install Flashlight by following [these instructions](https://docs.flashlight.dev/)
+3. The simplest way to get started is by running `yarn perf:measure` which will run a live preview of the performance test results. You can [see a demo here](https://github.com/bamlab/flashlight/assets/4534323/4038a342-f145-4c3b-8cde-17949bf52612)
+4. The `yarn perf:test:measure` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml` and give the results in `.perf/results.json` which can be viewed by running `yarn:perf:results`
+5. You can also run your own tests by running `yarn perf:test <path_to_test>` where `<path_to_test>` is the path to your test file. For example, `yarn perf:test __e2e__/maestro/scroll.yaml` will run the `scroll.yaml` test located in `__e2e__/maestro/scroll.yaml`.
\ No newline at end of file
diff --git a/jest/dev-infra/_common.sh b/jest/dev-infra/_common.sh
new file mode 100755
index 000000000..0d66653c8
--- /dev/null
+++ b/jest/dev-infra/_common.sh
@@ -0,0 +1,92 @@
+#!/usr/bin/env sh
+
+get_container_id() {
+  local compose_file=$1
+  local service=$2
+  if [ -z "${compose_file}" ] || [ -z "${service}" ]; then
+    echo "usage: get_container_id <compose_file> <service>"
+    exit 1
+  fi
+
+  docker compose -f $compose_file ps --format json --status running \
+    | jq -r '.[]? | select(.Service == "'${service}'") | .ID'
+}
+
+# Exports all environment variables
+export_env() {
+  export_pg_env
+  export_redis_env
+}
+
+# Exports postgres environment variables
+export_pg_env() {
+  # Based on creds in compose.yaml
+  export PGPORT=5433
+  export PGHOST=localhost
+  export PGUSER=pg
+  export PGPASSWORD=password
+  export PGDATABASE=postgres
+  export DB_POSTGRES_URL="postgresql://pg:password@127.0.0.1:5433/postgres"
+}
+
+# Exports redis environment variables
+export_redis_env() {
+  export REDIS_HOST="127.0.0.1:6380"
+}
+
+# Main entry point
+main() {
+  # Expect a SERVICES env var to be set with the docker service names
+  local services=${SERVICES}
+
+  dir=$(dirname $0)
+  compose_file="${dir}/docker-compose.yaml"
+
+  # whether this particular script started the container(s)
+  started_container=false
+
+  # trap SIGINT and performs cleanup as necessary, i.e.
+  # taking down containers if this script started them
+  trap "on_sigint ${services}" INT
+  on_sigint() {
+    local services=$@
+    echo # newline
+    if $started_container; then
+      docker compose -f $compose_file rm -f --stop --volumes ${services}
+    fi
+    exit $?
+  }
+
+  # check if all services are running already
+  not_running=false
+  for service in $services; do
+    container_id=$(get_container_id $compose_file $service)
+    if [ -z $container_id ]; then
+      not_running=true
+      break
+    fi
+  done
+
+  # if any are missing, recreate all services
+  if $not_running; then
+    docker compose -f $compose_file up --wait --force-recreate ${services}
+    started_container=true
+  else
+    echo "all services ${services} are already running"
+  fi
+
+  # setup environment variables and run args
+  export_env
+  "$@"
+  # save return code for later
+  code=$?
+
+  # performs cleanup as necessary, i.e. taking down containers
+  # if this script started them
+  echo # newline
+  if $started_container; then
+    docker compose -f $compose_file rm -f --stop --volumes ${services}
+  fi
+
+  exit ${code}
+}
diff --git a/jest/dev-infra/docker-compose.yaml b/jest/dev-infra/docker-compose.yaml
new file mode 100644
index 000000000..3d582c18b
--- /dev/null
+++ b/jest/dev-infra/docker-compose.yaml
@@ -0,0 +1,49 @@
+version: '3.8'
+services:
+  # An ephermerally-stored postgres database for single-use test runs
+  db_test: &db_test
+    image: postgres:14.4-alpine
+    environment:
+      - POSTGRES_USER=pg
+      - POSTGRES_PASSWORD=password
+    ports:
+      - '5433:5432'
+    # Healthcheck ensures db is queryable when `docker-compose up --wait` completes
+    healthcheck:
+      test: 'pg_isready -U pg'
+      interval: 500ms
+      timeout: 10s
+      retries: 20
+  # A persistently-stored postgres database
+  db:
+    <<: *db_test
+    ports:
+      - '5432:5432'
+    healthcheck:
+      disable: true
+    volumes:
+      - atp_db:/var/lib/postgresql/data
+  # An ephermerally-stored redis cache for single-use test runs
+  redis_test: &redis_test
+    image: redis:7.0-alpine
+    ports:
+      - '6380:6379'
+    # Healthcheck ensures redis is queryable when `docker-compose up --wait` completes
+    healthcheck:
+      test: ['CMD-SHELL', '[ "$$(redis-cli ping)" = "PONG" ]']
+      interval: 500ms
+      timeout: 10s
+      retries: 20
+  # A persistently-stored redis cache
+  redis:
+    <<: *redis_test
+    command: redis-server --save 60 1 --loglevel warning
+    ports:
+      - '6379:6379'
+    healthcheck:
+      disable: true
+    volumes:
+      - atp_redis:/data
+volumes:
+  atp_db:
+  atp_redis:
diff --git a/jest/dev-infra/with-test-db.sh b/jest/dev-infra/with-test-db.sh
new file mode 100755
index 000000000..cc083491a
--- /dev/null
+++ b/jest/dev-infra/with-test-db.sh
@@ -0,0 +1,9 @@
+#!/usr/bin/env sh
+
+# Example usage:
+# ./with-test-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;'
+
+dir=$(dirname $0)
+. ${dir}/_common.sh
+
+SERVICES="db_test" main "$@"
diff --git a/jest/dev-infra/with-test-redis-and-db.sh b/jest/dev-infra/with-test-redis-and-db.sh
new file mode 100755
index 000000000..c2b0c75ff
--- /dev/null
+++ b/jest/dev-infra/with-test-redis-and-db.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env sh
+
+# Example usage:
+# ./with-test-redis-and-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;'
+# ./with-test-redis-and-db.sh redis-cli -h localhost -p 6380 ping
+
+dir=$(dirname $0)
+. ${dir}/_common.sh
+
+SERVICES="db_test redis_test" main "$@"
diff --git a/jest/jestSetup.js b/jest/jestSetup.js
index 2629be2cc..5d6bd4f1f 100644
--- a/jest/jestSetup.js
+++ b/jest/jestSetup.js
@@ -74,3 +74,14 @@ jest.mock('lande', () => ({
   __esModule: true, // this property makes it work
   default: jest.fn().mockReturnValue([['eng']]),
 }))
+
+jest.mock('sentry-expo', () => ({
+  init: () => jest.fn(),
+  Native: {
+    ReactNativeTracing: jest.fn().mockImplementation(() => ({
+      start: jest.fn(),
+      stop: jest.fn(),
+    })),
+    ReactNavigationInstrumentation: jest.fn(),
+  },
+}))
diff --git a/jest/test-pds.ts b/jest/test-pds.ts
index 37ad824a0..bc3692600 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 {TestNetworkNoAppView} from '@atproto/dev-env'
+import {TestNetwork} from '@atproto/dev-env'
 import {AtUri, BskyAgent} from '@atproto/api'
 
 export interface TestUser {
@@ -18,14 +18,59 @@ export interface TestPDS {
   close: () => Promise<void>
 }
 
+class StringIdGenerator {
+  _nextId = [0]
+  constructor(
+    public _chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
+  ) {}
+
+  next() {
+    const r = []
+    for (const char of this._nextId) {
+      r.unshift(this._chars[char])
+    }
+    this._increment()
+    return r.join('')
+  }
+
+  _increment() {
+    for (let i = 0; i < this._nextId.length; i++) {
+      const val = ++this._nextId[i]
+      if (val >= this._chars.length) {
+        this._nextId[i] = 0
+      } else {
+        return
+      }
+    }
+    this._nextId.push(0)
+  }
+
+  *[Symbol.iterator]() {
+    while (true) {
+      yield this.next()
+    }
+  }
+}
+
+const ids = new StringIdGenerator()
+
 export async function createServer(
   {inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false},
 ): Promise<TestPDS> {
   const port = await getPort()
   const port2 = await getPort(port + 1)
   const pdsUrl = `http://localhost:${port}`
-  const testNet = await TestNetworkNoAppView.create({
-    pds: {port, publicUrl: pdsUrl, inviteRequired},
+  const id = ids.next()
+  const testNet = await TestNetwork.create({
+    pds: {
+      port,
+      publicUrl: pdsUrl,
+      inviteRequired,
+      dbPostgresSchema: `pds_${id}`,
+    },
+    bsky: {
+      dbPostgresSchema: `bsky_${id}`,
+    },
     plc: {port: port2},
   })
 
@@ -48,7 +93,7 @@ class Mocker {
   users: Record<string, TestUser> = {}
 
   constructor(
-    public testNet: TestNetworkNoAppView,
+    public testNet: TestNetwork,
     public service: string,
     public pic: Uint8Array,
   ) {
@@ -59,6 +104,10 @@ class Mocker {
     return this.testNet.pds
   }
 
+  get bsky() {
+    return this.testNet.bsky
+  }
+
   get plc() {
     return this.testNet.plc
   }
@@ -81,11 +130,7 @@ class Mocker {
     const inviteRes = await agent.api.com.atproto.server.createInviteCode(
       {useCount: 1},
       {
-        headers: {
-          authorization: `Basic ${btoa(
-            `admin:${this.pds.ctx.cfg.adminPassword}`,
-          )}`,
-        },
+        headers: this.pds.adminAuthHeaders('admin'),
         encoding: 'application/json',
       },
     )
@@ -260,11 +305,7 @@ class Mocker {
     await agent.api.com.atproto.server.createInviteCode(
       {useCount: 1, forAccount},
       {
-        headers: {
-          authorization: `Basic ${btoa(
-            `admin:${this.pds.ctx.cfg.adminPassword}`,
-          )}`,
-        },
+        headers: this.pds.adminAuthHeaders('admin'),
         encoding: 'application/json',
       },
     )
@@ -275,24 +316,21 @@ class Mocker {
     if (!did) {
       throw new Error(`Invalid user: ${user}`)
     }
-    const ctx = this.pds.ctx
+    const ctx = this.bsky.ctx
     if (!ctx) {
-      throw new Error('Invalid PDS')
+      throw new Error('Invalid appview')
     }
-
-    await ctx.db.db
-      .insertInto('label')
-      .values([
-        {
-          src: ctx.cfg.labelerDid,
-          uri: did,
-          cid: '',
-          val: label,
-          neg: 0,
-          cts: new Date().toISOString(),
-        },
-      ])
-      .execute()
+    const labelSrvc = ctx.services.label(ctx.db.getPrimary())
+    await labelSrvc.createLabels([
+      {
+        src: ctx.cfg.labelerDid,
+        uri: did,
+        cid: '',
+        val: label,
+        neg: false,
+        cts: new Date().toISOString(),
+      },
+    ])
   }
 
   async labelProfile(label: string, user: string) {
@@ -307,43 +345,39 @@ class Mocker {
       rkey: 'self',
     })
 
-    const ctx = this.pds.ctx
+    const ctx = this.bsky.ctx
     if (!ctx) {
-      throw new Error('Invalid PDS')
+      throw new Error('Invalid appview')
     }
-    await ctx.db.db
-      .insertInto('label')
-      .values([
-        {
-          src: ctx.cfg.labelerDid,
-          uri: profile.uri,
-          cid: profile.cid,
-          val: label,
-          neg: 0,
-          cts: new Date().toISOString(),
-        },
-      ])
-      .execute()
+    const labelSrvc = ctx.services.label(ctx.db.getPrimary())
+    await labelSrvc.createLabels([
+      {
+        src: ctx.cfg.labelerDid,
+        uri: profile.uri,
+        cid: profile.cid,
+        val: label,
+        neg: false,
+        cts: new Date().toISOString(),
+      },
+    ])
   }
 
   async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
-    const ctx = this.pds.ctx
+    const ctx = this.bsky.ctx
     if (!ctx) {
-      throw new Error('Invalid PDS')
+      throw new Error('Invalid appview')
     }
-    await ctx.db.db
-      .insertInto('label')
-      .values([
-        {
-          src: ctx.cfg.labelerDid,
-          uri,
-          cid,
-          val: label,
-          neg: 0,
-          cts: new Date().toISOString(),
-        },
-      ])
-      .execute()
+    const labelSrvc = ctx.services.label(ctx.db.getPrimary())
+    await labelSrvc.createLabels([
+      {
+        src: ctx.cfg.labelerDid,
+        uri,
+        cid,
+        val: label,
+        neg: false,
+        cts: new Date().toISOString(),
+      },
+    ])
   }
 
   async createMuteList(user: string, name: string): Promise<string> {
diff --git a/metro.config.js b/metro.config.js
index b1714479f..a49d95f9a 100644
--- a/metro.config.js
+++ b/metro.config.js
@@ -1,7 +1,25 @@
 // Learn more https://docs.expo.io/guides/customizing-metro
 const {getDefaultConfig} = require('expo/metro-config')
 const cfg = getDefaultConfig(__dirname)
+
 cfg.resolver.sourceExts = process.env.RN_SRC_EXT
   ? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts)
   : cfg.resolver.sourceExts
+
+cfg.transformer.getTransformOptions = async () => ({
+  transform: {
+    experimentalImportSupport: true,
+    inlineRequires: true,
+    nonInlinedRequires: [
+      // We can remove this option and rely on the default after
+      // https://github.com/facebook/metro/pull/1126 is released.
+      'React',
+      'react',
+      'react/jsx-dev-runtime',
+      'react/jsx-runtime',
+      'react-native',
+    ],
+  },
+})
+
 module.exports = cfg
diff --git a/package.json b/package.json
index a886bfcd0..3a91528cc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "bsky.app",
-  "version": "1.51.0",
+  "version": "1.55.0",
   "private": true,
   "scripts": {
     "prepare": "is-ci || husky install",
@@ -11,6 +11,7 @@
     "web": "expo start --web",
     "build-web": "expo export:web && node ./scripts/post-web-build.js && cp --verbose ./web-build/static/js/*.* ./bskyweb/static/js/",
     "start": "expo start --dev-client",
+    "start:prod": "expo start --dev-client --no-dev --minify",
     "clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
     "test": "jest --forceExit --testTimeout=20000 --bail",
     "test-watch": "jest --watchAll",
@@ -18,14 +19,19 @@
     "test-coverage": "jest --coverage",
     "lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
     "typecheck": "tsc --project ./tsconfig.check.json",
-    "e2e:mock-server": "ts-node __e2e__/mock-server.ts",
+    "e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node __e2e__/mock-server.ts",
     "e2e:metro": "RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
     "e2e:build": "detox build -c ios.sim.debug",
     "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all",
+    "perf:test": "maestro test",
+    "perf:test:run": "maestro test __e2e__/maestro/scroll.yaml",
+    "perf:test:measure": "flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
+    "perf:test:results": "flashlight report .perf/results.json",
+    "perf:measure": "flashlight measure",
     "build:apk": "eas build -p android --profile dev-android-apk"
   },
   "dependencies": {
-    "@atproto/api": "^0.6.20",
+    "@atproto/api": "^0.6.21",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
     "@braintree/sanitize-url": "^6.0.2",
     "@emoji-mart/react": "^1.1.1",
@@ -53,7 +59,7 @@
     "@segment/analytics-react": "^1.0.0-rc1",
     "@segment/analytics-react-native": "^2.10.1",
     "@segment/sovran-react-native": "^0.4.5",
-    "@sentry/react-native": "5.5.0",
+    "@sentry/react-native": "5.10.0",
     "@tanstack/react-query": "^4.33.0",
     "@tiptap/core": "^2.0.0-beta.220",
     "@tiptap/extension-document": "^2.0.0-beta.220",
@@ -71,6 +77,7 @@
     "@zxing/text-encoding": "^0.9.0",
     "array.prototype.findlast": "^1.2.3",
     "await-lock": "^2.2.2",
+    "babel-plugin-transform-remove-console": "^6.9.4",
     "base64-js": "^1.5.1",
     "bcp-47-match": "^2.0.3",
     "email-validator": "^2.0.4",
@@ -148,7 +155,7 @@
     "react-native-web-linear-gradient": "^1.1.2",
     "react-responsive": "^9.0.2",
     "rn-fetch-blob": "^0.12.0",
-    "sentry-expo": "~7.0.0",
+    "sentry-expo": "~7.0.1",
     "tippy.js": "^6.3.7",
     "tlds": "^1.234.0",
     "zeego": "^1.6.2",
@@ -186,7 +193,7 @@
     "babel-loader": "^9.1.2",
     "babel-plugin-module-resolver": "^5.0.0",
     "babel-plugin-react-native-web": "^0.18.12",
-    "detox": "^20.11.3",
+    "detox": "^20.13.0",
     "eslint": "^8.19.0",
     "eslint-plugin-detox": "^1.0.0",
     "eslint-plugin-ft-flow": "^2.0.3",
@@ -213,7 +220,8 @@
     "webpack-dev-server": "^4.11.1"
   },
   "resolutions": {
-    "@types/react": "^18"
+    "@types/react": "^18",
+    "**/zeed-dom": "0.10.9"
   },
   "jest": {
     "preset": "jest-expo/ios",
diff --git a/patches/@sentry+react-native+5.5.0.patch b/patches/@sentry+react-native+5.10.0.patch
index 5ff4ddaba..2962aa44c 100644
--- a/patches/@sentry+react-native+5.5.0.patch
+++ b/patches/@sentry+react-native+5.10.0.patch
@@ -1,5 +1,5 @@
 diff --git a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
-index 7e0b4cd..3fd7406 100644
+index 7e0b4cd..177454c 100644
 --- a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
 +++ b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
 @@ -3,6 +3,8 @@ import { LogBox } from 'react-native';
@@ -12,3 +12,4 @@ index 7e0b4cd..3fd7406 100644
 +    } catch (e) {}
  }
  //# sourceMappingURL=ignorerequirecyclelogs.js.map
+\ No newline at end of file
diff --git a/patches/react-native-pager-view+6.1.4.patch b/patches/react-native-pager-view+6.1.4.patch
index adee2533f..d6b4178ab 100644
--- a/patches/react-native-pager-view+6.1.4.patch
+++ b/patches/react-native-pager-view+6.1.4.patch
@@ -1,5 +1,5 @@
 diff --git a/node_modules/react-native-pager-view/ios/ReactNativePageView.m b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
-index ab0fc7f..fbbf19f 100644
+index ab0fc7f..1ace752 100644
 --- a/node_modules/react-native-pager-view/ios/ReactNativePageView.m
 +++ b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
 @@ -1,6 +1,6 @@
@@ -19,7 +19,7 @@ index ab0fc7f..fbbf19f 100644
  
  @property(nonatomic, strong) UIPageViewController *reactPageViewController;
  @property(nonatomic, strong) RCTEventDispatcher *eventDispatcher;
-@@ -80,6 +80,10 @@
+@@ -80,6 +80,10 @@ - (void)didMoveToWindow {
          [self setupInitialController];
      }
  
@@ -30,13 +30,13 @@ index ab0fc7f..fbbf19f 100644
      if (self.reactViewController.navigationController != nil && self.reactViewController.navigationController.interactivePopGestureRecognizer != nil) {
          [self.scrollView.panGestureRecognizer requireGestureRecognizerToFail:self.reactViewController.navigationController.interactivePopGestureRecognizer];
      }
-@@ -463,4 +467,21 @@
+@@ -463,4 +467,21 @@ - (NSString *)determineScrollDirection:(UIScrollView *)scrollView {
  - (BOOL)isLtrLayout {
      return [_layoutDirection isEqualToString:@"ltr"];
  }
 +
 +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
-+    if (otherGestureRecognizer == self.scrollView.panGestureRecognizer) {
++    if (!_overdrag && otherGestureRecognizer == self.scrollView.panGestureRecognizer) {
 +        UIPanGestureRecognizer* p = (UIPanGestureRecognizer*) gestureRecognizer;
 +        CGPoint velocity = [p velocityInView:self];
 +        if (self.currentIndex == 0 && velocity.x > 0) {
diff --git a/src/App.native.tsx b/src/App.native.tsx
index f99e976ce..f4298c461 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -2,7 +2,6 @@ import 'react-native-url-polyfill/auto'
 import React, {useState, useEffect} from 'react'
 import 'lib/sentry' // must be relatively on top
 import {withSentry} from 'lib/sentry'
-import {Linking} from 'react-native'
 import {RootSiblingParent} from 'react-native-root-siblings'
 import * as SplashScreen from 'expo-splash-screen'
 import {GestureHandlerRootView} from 'react-native-gesture-handler'
@@ -15,7 +14,6 @@ import {Shell} from './view/shell'
 import * as notifications from 'lib/notifications/notifications'
 import * as analytics from 'lib/analytics/analytics'
 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'
@@ -34,15 +32,6 @@ const App = observer(function AppImpl() {
       setRootStore(store)
       analytics.init(store)
       notifications.init(store)
-      SplashScreen.hideAsync()
-      Linking.getInitialURL().then((url: string | null) => {
-        if (url) {
-          handleLink(url)
-        }
-      })
-      Linking.addEventListener('url', ({url}) => {
-        handleLink(url)
-      })
       store.onSessionDropped(() => {
         Toast.show('Sorry! Your session expired. Please log in again.')
       })
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 97612c9ec..52235ad75 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -1,5 +1,6 @@
 import * as React from 'react'
 import {StyleSheet} from 'react-native'
+import * as SplashScreen from 'expo-splash-screen'
 import {observer} from 'mobx-react-lite'
 import {
   NavigationContainer,
@@ -91,42 +92,42 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
     <>
       <Stack.Screen
         name="NotFound"
-        component={NotFoundScreen}
+        getComponent={() => NotFoundScreen}
         options={{title: title('Not Found')}}
       />
       <Stack.Screen
         name="Moderation"
-        component={ModerationScreen}
+        getComponent={() => ModerationScreen}
         options={{title: title('Moderation')}}
       />
       <Stack.Screen
         name="ModerationMuteLists"
-        component={ModerationMuteListsScreen}
+        getComponent={() => ModerationMuteListsScreen}
         options={{title: title('Mute Lists')}}
       />
       <Stack.Screen
         name="ModerationMutedAccounts"
-        component={ModerationMutedAccounts}
+        getComponent={() => ModerationMutedAccounts}
         options={{title: title('Muted Accounts')}}
       />
       <Stack.Screen
         name="ModerationBlockedAccounts"
-        component={ModerationBlockedAccounts}
+        getComponent={() => ModerationBlockedAccounts}
         options={{title: title('Blocked Accounts')}}
       />
       <Stack.Screen
         name="Settings"
-        component={SettingsScreen}
+        getComponent={() => SettingsScreen}
         options={{title: title('Settings')}}
       />
       <Stack.Screen
         name="LanguageSettings"
-        component={LanguageSettingsScreen}
+        getComponent={() => LanguageSettingsScreen}
         options={{title: title('Language Settings')}}
       />
       <Stack.Screen
         name="Profile"
-        component={ProfileScreen}
+        getComponent={() => ProfileScreen}
         options={({route}) => ({
           title: title(`@${route.params.name}`),
           animation: 'none',
@@ -134,101 +135,101 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
       />
       <Stack.Screen
         name="ProfileFollowers"
-        component={ProfileFollowersScreen}
+        getComponent={() => ProfileFollowersScreen}
         options={({route}) => ({
           title: title(`People following @${route.params.name}`),
         })}
       />
       <Stack.Screen
         name="ProfileFollows"
-        component={ProfileFollowsScreen}
+        getComponent={() => ProfileFollowsScreen}
         options={({route}) => ({
           title: title(`People followed by @${route.params.name}`),
         })}
       />
       <Stack.Screen
         name="ProfileList"
-        component={ProfileListScreen}
+        getComponent={() => ProfileListScreen}
         options={{title: title('Mute List')}}
       />
       <Stack.Screen
         name="PostThread"
-        component={PostThreadScreen}
+        getComponent={() => PostThreadScreen}
         options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
       />
       <Stack.Screen
         name="PostLikedBy"
-        component={PostLikedByScreen}
+        getComponent={() => PostLikedByScreen}
         options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
       />
       <Stack.Screen
         name="PostRepostedBy"
-        component={PostRepostedByScreen}
+        getComponent={() => PostRepostedByScreen}
         options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
       />
       <Stack.Screen
         name="CustomFeed"
-        component={CustomFeedScreen}
+        getComponent={() => CustomFeedScreen}
         options={{title: title('Feed')}}
       />
       <Stack.Screen
         name="CustomFeedLikedBy"
-        component={CustomFeedLikedByScreen}
+        getComponent={() => CustomFeedLikedByScreen}
         options={{title: title('Liked by')}}
       />
       <Stack.Screen
         name="Debug"
-        component={DebugScreen}
+        getComponent={() => DebugScreen}
         options={{title: title('Debug')}}
       />
       <Stack.Screen
         name="Log"
-        component={LogScreen}
+        getComponent={() => LogScreen}
         options={{title: title('Log')}}
       />
       <Stack.Screen
         name="Support"
-        component={SupportScreen}
+        getComponent={() => SupportScreen}
         options={{title: title('Support')}}
       />
       <Stack.Screen
         name="PrivacyPolicy"
-        component={PrivacyPolicyScreen}
+        getComponent={() => PrivacyPolicyScreen}
         options={{title: title('Privacy Policy')}}
       />
       <Stack.Screen
         name="TermsOfService"
-        component={TermsOfServiceScreen}
+        getComponent={() => TermsOfServiceScreen}
         options={{title: title('Terms of Service')}}
       />
       <Stack.Screen
         name="CommunityGuidelines"
-        component={CommunityGuidelinesScreen}
+        getComponent={() => CommunityGuidelinesScreen}
         options={{title: title('Community Guidelines')}}
       />
       <Stack.Screen
         name="CopyrightPolicy"
-        component={CopyrightPolicyScreen}
+        getComponent={() => CopyrightPolicyScreen}
         options={{title: title('Copyright Policy')}}
       />
       <Stack.Screen
         name="AppPasswords"
-        component={AppPasswords}
+        getComponent={() => AppPasswords}
         options={{title: title('App Passwords')}}
       />
       <Stack.Screen
         name="SavedFeeds"
-        component={SavedFeeds}
+        getComponent={() => SavedFeeds}
         options={{title: title('Edit My Feeds')}}
       />
       <Stack.Screen
         name="PreferencesHomeFeed"
-        component={PreferencesHomeFeed}
+        getComponent={() => PreferencesHomeFeed}
         options={{title: title('Home Feed Preferences')}}
       />
       <Stack.Screen
         name="PreferencesThreads"
-        component={PreferencesThreads}
+        getComponent={() => PreferencesThreads}
         options={{title: title('Threads Preferences')}}
       />
     </>
@@ -253,14 +254,17 @@ function TabsNavigator() {
       backBehavior="initialRoute"
       screenOptions={{headerShown: false, lazy: true}}
       tabBar={tabBar}>
-      <Tab.Screen name="HomeTab" component={HomeTabNavigator} />
-      <Tab.Screen name="SearchTab" component={SearchTabNavigator} />
-      <Tab.Screen name="FeedsTab" component={FeedsTabNavigator} />
+      <Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} />
+      <Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} />
+      <Tab.Screen name="FeedsTab" getComponent={() => FeedsTabNavigator} />
       <Tab.Screen
         name="NotificationsTab"
-        component={NotificationsTabNavigator}
+        getComponent={() => NotificationsTabNavigator}
+      />
+      <Tab.Screen
+        name="MyProfileTab"
+        getComponent={() => MyProfileTabNavigator}
       />
-      <Tab.Screen name="MyProfileTab" component={MyProfileTabNavigator} />
     </Tab.Navigator>
   )
 }
@@ -277,7 +281,7 @@ function HomeTabNavigator() {
         animationDuration: 250,
         contentStyle,
       }}>
-      <HomeTab.Screen name="Home" component={HomeScreen} />
+      <HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
       {commonScreens(HomeTab)}
     </HomeTab.Navigator>
   )
@@ -294,7 +298,7 @@ function SearchTabNavigator() {
         animationDuration: 250,
         contentStyle,
       }}>
-      <SearchTab.Screen name="Search" component={SearchScreen} />
+      <SearchTab.Screen name="Search" getComponent={() => SearchScreen} />
       {commonScreens(SearchTab as typeof HomeTab)}
     </SearchTab.Navigator>
   )
@@ -311,7 +315,7 @@ function FeedsTabNavigator() {
         animationDuration: 250,
         contentStyle,
       }}>
-      <FeedsTab.Screen name="Feeds" component={FeedsScreen} />
+      <FeedsTab.Screen name="Feeds" getComponent={() => FeedsScreen} />
       {commonScreens(FeedsTab as typeof HomeTab)}
     </FeedsTab.Navigator>
   )
@@ -330,7 +334,7 @@ function NotificationsTabNavigator() {
       }}>
       <NotificationsTab.Screen
         name="Notifications"
-        component={NotificationsScreen}
+        getComponent={() => NotificationsScreen}
       />
       {commonScreens(NotificationsTab as typeof HomeTab)}
     </NotificationsTab.Navigator>
@@ -352,7 +356,7 @@ const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
       <MyProfileTab.Screen
         name="MyProfile"
         // @ts-ignore // TODO: fix this broken type in ProfileScreen
-        component={ProfileScreen}
+        getComponent={() => ProfileScreen}
         initialParams={{
           name: store.me.did,
         }}
@@ -383,22 +387,22 @@ const FlatNavigator = observer(function FlatNavigatorImpl() {
       }}>
       <Flat.Screen
         name="Home"
-        component={HomeScreen}
+        getComponent={() => HomeScreen}
         options={{title: title('Home')}}
       />
       <Flat.Screen
         name="Search"
-        component={SearchScreen}
+        getComponent={() => SearchScreen}
         options={{title: title('Search')}}
       />
       <Flat.Screen
         name="Feeds"
-        component={FeedsScreen}
+        getComponent={() => FeedsScreen}
         options={{title: title('Feeds')}}
       />
       <Flat.Screen
         name="Notifications"
-        component={NotificationsScreen}
+        getComponent={() => NotificationsScreen}
         options={{title: title('Notifications')}}
       />
       {commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
@@ -462,6 +466,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
       linking={LINKING}
       theme={theme}
       onReady={() => {
+        SplashScreen.hideAsync()
         // Register the navigation container with the Sentry instrumentation (only works on native)
         if (isNative) {
           const routingInstrumentation = getRoutingInstrumentation()
@@ -483,9 +488,21 @@ function navigate<K extends keyof AllNavigatorParams>(
   params?: AllNavigatorParams[K],
 ) {
   if (navigationRef.isReady()) {
-    // @ts-ignore I dont know what would make typescript happy but I have a life -prf
-    navigationRef.navigate(name, params)
+    return Promise.race([
+      new Promise<void>(resolve => {
+        const handler = () => {
+          resolve()
+          navigationRef.removeListener('state', handler)
+        }
+        navigationRef.addListener('state', handler)
+
+        // @ts-ignore I dont know what would make typescript happy but I have a life -prf
+        navigationRef.navigate(name, params)
+      }),
+      timeout(1e3),
+    ])
   }
+  return Promise.resolve()
 }
 
 function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') {
diff --git a/src/lib/analytics/analytics.tsx b/src/lib/analytics/analytics.tsx
index d1eb50f8a..b3db9149c 100644
--- a/src/lib/analytics/analytics.tsx
+++ b/src/lib/analytics/analytics.tsx
@@ -51,10 +51,10 @@ export function init(store: RootStoreModel) {
   store.onSessionLoaded(() => {
     const sess = store.session.currentSession
     if (sess) {
-      if (sess.email) {
+      if (sess.did) {
+        const did_hashed = sha256(sess.did)
+        segmentClient.identify(did_hashed, {did_hashed})
         store.log.debug('Ping w/hash')
-        const email_hashed = sha256(sess.email)
-        segmentClient.identify(email_hashed, {email_hashed})
       } else {
         store.log.debug('Ping w/o hash')
         segmentClient.identify()
diff --git a/src/lib/analytics/analytics.web.tsx b/src/lib/analytics/analytics.web.tsx
index db9d86e3c..78bd9b42b 100644
--- a/src/lib/analytics/analytics.web.tsx
+++ b/src/lib/analytics/analytics.web.tsx
@@ -46,10 +46,10 @@ export function init(store: RootStoreModel) {
   store.onSessionLoaded(() => {
     const sess = store.session.currentSession
     if (sess) {
-      if (sess.email) {
+      if (sess.did) {
+        const did_hashed = sha256(sess.did)
+        segmentClient.identify(did_hashed, {did_hashed})
         store.log.debug('Ping w/hash')
-        const email_hashed = sha256(sess.email)
-        segmentClient.identify(email_hashed, {email_hashed})
       } else {
         store.log.debug('Ping w/o hash')
         segmentClient.identify()
diff --git a/src/lib/hooks/useMinimalShellMode.tsx b/src/lib/hooks/useMinimalShellMode.tsx
index e28a0e884..475d165d3 100644
--- a/src/lib/hooks/useMinimalShellMode.tsx
+++ b/src/lib/hooks/useMinimalShellMode.tsx
@@ -1,32 +1,60 @@
 import React from 'react'
+import {autorun} from 'mobx'
 import {useStores} from 'state/index'
-import {Animated} from 'react-native'
-import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
+import {
+  Easing,
+  interpolate,
+  useAnimatedStyle,
+  useSharedValue,
+  withTiming,
+} from 'react-native-reanimated'
 
 export function useMinimalShellMode() {
   const store = useStores()
-  const minimalShellInterp = useAnimatedValue(0)
-  const footerMinimalShellTransform = {
-    transform: [{translateY: Animated.multiply(minimalShellInterp, 100)}],
-  }
+  const minimalShellInterp = useSharedValue(0)
+  const footerMinimalShellTransform = useAnimatedStyle(() => {
+    return {
+      opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
+      transform: [
+        {translateY: interpolate(minimalShellInterp.value, [0, 1], [0, 25])},
+      ],
+    }
+  })
+  const headerMinimalShellTransform = useAnimatedStyle(() => {
+    return {
+      opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
+      transform: [
+        {translateY: interpolate(minimalShellInterp.value, [0, 1], [0, -25])},
+      ],
+    }
+  })
+  const fabMinimalShellTransform = useAnimatedStyle(() => {
+    return {
+      transform: [
+        {translateY: interpolate(minimalShellInterp.value, [0, 1], [-44, 0])},
+      ],
+    }
+  })
 
   React.useEffect(() => {
-    if (store.shell.minimalShellMode) {
-      Animated.timing(minimalShellInterp, {
-        toValue: 1,
-        duration: 100,
-        useNativeDriver: true,
-        isInteraction: false,
-      }).start()
-    } else {
-      Animated.timing(minimalShellInterp, {
-        toValue: 0,
-        duration: 100,
-        useNativeDriver: true,
-        isInteraction: false,
-      }).start()
-    }
+    return autorun(() => {
+      if (store.shell.minimalShellMode) {
+        minimalShellInterp.value = withTiming(1, {
+          duration: 125,
+          easing: Easing.bezier(0.25, 0.1, 0.25, 1),
+        })
+      } else {
+        minimalShellInterp.value = withTiming(0, {
+          duration: 125,
+          easing: Easing.bezier(0.25, 0.1, 0.25, 1),
+        })
+      }
+    })
   }, [minimalShellInterp, store.shell.minimalShellMode])
 
-  return {footerMinimalShellTransform}
+  return {
+    footerMinimalShellTransform,
+    headerMinimalShellTransform,
+    fabMinimalShellTransform,
+  }
 }
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 3c27d8639..106d2ca31 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -170,15 +170,32 @@ export function getYoutubeVideoId(link: string): string | undefined {
 
 export function linkRequiresWarning(uri: string, label: string) {
   const labelDomain = labelToDomain(label)
-  if (!labelDomain) {
-    return true
-  }
+  let urip
   try {
-    const urip = new URL(uri)
-    return labelDomain !== urip.hostname
+    urip = new URL(uri)
   } catch {
     return true
   }
+
+  if (urip.hostname === 'bsky.app') {
+    // if this is a link to internal content,
+    // warn if it represents itself as a URL to another app
+    if (
+      labelDomain &&
+      labelDomain !== 'bsky.app' &&
+      isPossiblyAUrl(labelDomain)
+    ) {
+      return true
+    }
+    return false
+  } else {
+    // if this is a link to external content,
+    // warn if the label doesnt match the target
+    if (!labelDomain) {
+      return true
+    }
+    return labelDomain !== urip.hostname
+  }
 }
 
 function labelToDomain(label: string): string | undefined {
diff --git a/src/state/models/cache/my-follows.ts b/src/state/models/cache/my-follows.ts
index 07079b5af..e1e8af509 100644
--- a/src/state/models/cache/my-follows.ts
+++ b/src/state/models/cache/my-follows.ts
@@ -5,6 +5,7 @@ import {
   moderateProfile,
 } from '@atproto/api'
 import {RootStoreModel} from '../root-store'
+import {bundleAsync} from 'lib/async/bundle'
 
 const MAX_SYNC_PAGES = 10
 const SYNC_TTL = 60e3 * 10 // 10 minutes
@@ -56,7 +57,7 @@ export class MyFollowsCache {
    * Syncs a subset of the user's follows
    * for performance reasons, caps out at 1000 follows
    */
-  async syncIfNeeded() {
+  syncIfNeeded = bundleAsync(async () => {
     if (this.lastSync > Date.now() - SYNC_TTL) {
       return
     }
@@ -81,7 +82,7 @@ export class MyFollowsCache {
     }
 
     this.lastSync = Date.now()
-  }
+  })
 
   getFollowState(did: string): FollowState {
     if (typeof this.byDid[did] === 'undefined') {
diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts
index ae4f29105..d46cced75 100644
--- a/src/state/models/feeds/post.ts
+++ b/src/state/models/feeds/post.ts
@@ -116,6 +116,7 @@ export class PostsFeedItemModel {
           },
           () => this.rootStore.agent.deleteLike(url),
         )
+        track('Post:Unlike')
       } else {
         // like
         await updateDataOptimistically(
@@ -129,11 +130,10 @@ export class PostsFeedItemModel {
             this.post.viewer!.like = res.uri
           },
         )
+        track('Post:Like')
       }
     } catch (error) {
       this.rootStore.log.error('Failed to toggle like', error)
-    } finally {
-      track(this.post.viewer.like ? 'Post:Unlike' : 'Post:Like')
     }
   }
 
@@ -141,6 +141,7 @@ export class PostsFeedItemModel {
     this.post.viewer = this.post.viewer || {}
     try {
       if (this.post.viewer?.repost) {
+        // unrepost
         const url = this.post.viewer.repost
         await updateDataOptimistically(
           this.post,
@@ -150,7 +151,9 @@ export class PostsFeedItemModel {
           },
           () => this.rootStore.agent.deleteRepost(url),
         )
+        track('Post:Unrepost')
       } else {
+        // repost
         await updateDataOptimistically(
           this.post,
           () => {
@@ -162,11 +165,10 @@ export class PostsFeedItemModel {
             this.post.viewer!.repost = res.uri
           },
         )
+        track('Post:Repost')
       }
     } catch (error) {
       this.rootStore.log.error('Failed to toggle repost', error)
-    } finally {
-      track(this.post.viewer.repost ? 'Post:Unrepost' : 'Post:Repost')
     }
   }
 
@@ -174,13 +176,13 @@ export class PostsFeedItemModel {
     try {
       if (this.isThreadMuted) {
         this.rootStore.mutedThreads.uris.delete(this.rootUri)
+        track('Post:ThreadUnmute')
       } else {
         this.rootStore.mutedThreads.uris.add(this.rootUri)
+        track('Post:ThreadMute')
       }
     } catch (error) {
       this.rootStore.log.error('Failed to toggle thread mute', error)
-    } finally {
-      track(this.isThreadMuted ? 'Post:ThreadUnmute' : 'Post:ThreadMute')
     }
   }
 
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index 8a7a4c851..186e61cf6 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -25,13 +25,13 @@ export class MeModel {
   savedFeeds: SavedFeedsModel
   notifications: NotificationsFeedModel
   follows: MyFollowsCache
-  invites: ComAtprotoServerDefs.InviteCode[] | null = []
+  invites: ComAtprotoServerDefs.InviteCode[] = []
   appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = []
   lastProfileStateUpdate = Date.now()
   lastNotifsUpdate = Date.now()
 
   get invitesAvailable() {
-    return this.invites?.filter(isInviteAvailable).length || null
+    return this.invites.filter(isInviteAvailable).length
   }
 
   constructor(public rootStore: RootStoreModel) {
@@ -180,9 +180,7 @@ export class MeModel {
       } catch (e) {
         this.rootStore.log.error('Failed to fetch user invite codes', e)
       }
-      if (this.invites) {
-        await this.rootStore.invitedUsers.fetch(this.invites)
-      }
+      await this.rootStore.invitedUsers.fetch(this.invites)
     }
   }
 
diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts
index 10aef0ff4..c26f9b87c 100644
--- a/src/state/models/media/image.ts
+++ b/src/state/models/media/image.ts
@@ -166,7 +166,7 @@ export class ImageModel implements Omit<RNImage, 'size'> {
   async crop() {
     try {
       // NOTE
-      // on ios, react-native-image-cropper gives really bad quality
+      // on ios, react-native-image-crop-picker gives really bad quality
       // without specifying width and height. on android, however, the
       // crop stretches incorrectly if you do specify it. these are
       // both separate bugs in the library. we deal with that by
diff --git a/src/state/models/ui/reminders.e2e.ts b/src/state/models/ui/reminders.e2e.ts
new file mode 100644
index 000000000..ec0eca40d
--- /dev/null
+++ b/src/state/models/ui/reminders.e2e.ts
@@ -0,0 +1,24 @@
+import {makeAutoObservable} from 'mobx'
+import {RootStoreModel} from '../root-store'
+
+export class Reminders {
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(
+      this,
+      {serialize: false, hydrate: false},
+      {autoBind: true},
+    )
+  }
+
+  serialize() {
+    return {}
+  }
+
+  hydrate(_v: unknown) {}
+
+  get shouldRequestEmailConfirmation() {
+    return false
+  }
+
+  setEmailConfirmationRequested() {}
+}
diff --git a/src/state/models/ui/reminders.ts b/src/state/models/ui/reminders.ts
index 60dbf5d88..c650de004 100644
--- a/src/state/models/ui/reminders.ts
+++ b/src/state/models/ui/reminders.ts
@@ -3,10 +3,8 @@ import {isObj, hasProp} from 'lib/type-guards'
 import {RootStoreModel} from '../root-store'
 import {toHashCode} from 'lib/strings/helpers'
 
-const DAY = 60e3 * 24 * 1 // 1 day (ms)
-
 export class Reminders {
-  lastEmailConfirm: Date = new Date()
+  lastEmailConfirm: Date | null = null
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(
@@ -45,6 +43,10 @@ export class Reminders {
     if (this.rootStore.onboarding.isActive) {
       return false
     }
+    // only prompt once
+    if (this.lastEmailConfirm) {
+      return false
+    }
     const today = new Date()
     // shard the users into 2 day of the week buckets
     // (this is to avoid a sudden influx of email updates when
@@ -53,9 +55,7 @@ export class Reminders {
     if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) {
       return false
     }
-    // only ask once a day at most, but because of the bucketing
-    // this will be more like weekly
-    return Number(today) - Number(this.lastEmailConfirm) > DAY
+    return true
   }
 
   setEmailConfirmationRequested() {
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index c5d094ea5..2810129f6 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -187,16 +187,19 @@ export const TextInput = forwardRef(function TextInputImpl(
   const textDecorated = useMemo(() => {
     let i = 0
 
-    return Array.from(richtext.segments()).map(segment => (
-      <Text
-        key={i++}
-        style={[
-          !segment.facet ? pal.text : pal.link,
-          styles.textInputFormatting,
-        ]}>
-        {segment.text}
-      </Text>
-    ))
+    return Array.from(richtext.segments()).map(segment => {
+      const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0])
+      return (
+        <Text
+          key={i++}
+          style={[
+            segment.facet && !isTag ? pal.link : pal.text,
+            styles.textInputFormatting,
+          ]}>
+          {segment.text}
+        </Text>
+      )
+    })
   }, [richtext, pal.link, pal.text])
 
   return (
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
new file mode 100644
index 000000000..725106d59
--- /dev/null
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -0,0 +1,210 @@
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {useIsFocused} from '@react-navigation/native'
+import {useAnalytics} from '@segment/analytics-react-native'
+import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {ComposeIcon2} from 'lib/icons'
+import {colors, s} from 'lib/styles'
+import {observer} from 'mobx-react-lite'
+import React from 'react'
+import {FlatList, View} from 'react-native'
+import {useStores} from 'state/index'
+import {PostsFeedModel} from 'state/models/feeds/posts'
+import {useHeaderOffset, POLL_FREQ} from 'view/screens/Home'
+import {Feed} from '../posts/Feed'
+import {TextLink} from '../util/Link'
+import {FAB} from '../util/fab/FAB'
+import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
+import useAppState from 'react-native-appstate-hook'
+
+export const FeedPage = observer(function FeedPageImpl({
+  testID,
+  isPageFocused,
+  feed,
+  renderEmptyState,
+  renderEndOfFeed,
+}: {
+  testID?: string
+  feed: PostsFeedModel
+  isPageFocused: boolean
+  renderEmptyState: () => JSX.Element
+  renderEndOfFeed?: () => JSX.Element
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+  const {isDesktop} = useWebMediaQueries()
+  const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store)
+  const {screen, track} = useAnalytics()
+  const headerOffset = useHeaderOffset()
+  const scrollElRef = React.useRef<FlatList>(null)
+  const {appState} = useAppState({
+    onForeground: () => doPoll(true),
+  })
+  const isScreenFocused = useIsFocused()
+  const hasNew = feed.hasNewLatest && !feed.isRefreshing
+
+  React.useEffect(() => {
+    // called on first load
+    if (!feed.hasLoaded && isPageFocused) {
+      feed.setup()
+    }
+  }, [isPageFocused, feed])
+
+  const doPoll = React.useCallback(
+    (knownActive = false) => {
+      if (
+        (!knownActive && appState !== 'active') ||
+        !isScreenFocused ||
+        !isPageFocused
+      ) {
+        return
+      }
+      if (feed.isLoading) {
+        return
+      }
+      store.log.debug('HomeScreen: Polling for new posts')
+      feed.checkForLatest()
+    },
+    [appState, isScreenFocused, isPageFocused, store, feed],
+  )
+
+  const scrollToTop = React.useCallback(() => {
+    scrollElRef.current?.scrollToOffset({offset: -headerOffset})
+    resetMainScroll()
+  }, [headerOffset, resetMainScroll])
+
+  const onSoftReset = React.useCallback(() => {
+    if (isPageFocused) {
+      scrollToTop()
+      feed.refresh()
+    }
+  }, [isPageFocused, scrollToTop, feed])
+
+  // fires when page within screen is activated/deactivated
+  // - check for latest
+  React.useEffect(() => {
+    if (!isPageFocused || !isScreenFocused) {
+      return
+    }
+
+    const softResetSub = store.onScreenSoftReset(onSoftReset)
+    const feedCleanup = feed.registerListeners()
+    const pollInterval = setInterval(doPoll, POLL_FREQ)
+
+    screen('Feed')
+    store.log.debug('HomeScreen: Updating feed')
+    feed.checkForLatest()
+
+    return () => {
+      clearInterval(pollInterval)
+      softResetSub.remove()
+      feedCleanup()
+    }
+  }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused])
+
+  const onPressCompose = React.useCallback(() => {
+    track('HomeScreen:PressCompose')
+    store.shell.openComposer({})
+  }, [store, track])
+
+  const onPressTryAgain = React.useCallback(() => {
+    feed.refresh()
+  }, [feed])
+
+  const onPressLoadLatest = React.useCallback(() => {
+    scrollToTop()
+    feed.refresh()
+  }, [feed, scrollToTop])
+
+  const ListHeaderComponent = React.useCallback(() => {
+    if (isDesktop) {
+      return (
+        <View
+          style={[
+            pal.view,
+            {
+              flexDirection: 'row',
+              alignItems: 'center',
+              justifyContent: 'space-between',
+              paddingHorizontal: 18,
+              paddingVertical: 12,
+            },
+          ]}>
+          <TextLink
+            type="title-lg"
+            href="/"
+            style={[pal.text, {fontWeight: 'bold'}]}
+            text={
+              <>
+                {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
+                {hasNew && (
+                  <View
+                    style={{
+                      top: -8,
+                      backgroundColor: colors.blue3,
+                      width: 8,
+                      height: 8,
+                      borderRadius: 4,
+                    }}
+                  />
+                )}
+              </>
+            }
+            onPress={() => store.emitScreenSoftReset()}
+          />
+          <TextLink
+            type="title-lg"
+            href="/settings/home-feed"
+            style={{fontWeight: 'bold'}}
+            accessibilityLabel="Feed Preferences"
+            accessibilityHint=""
+            text={
+              <FontAwesomeIcon
+                icon="sliders"
+                style={pal.textLight as FontAwesomeIconStyle}
+              />
+            }
+          />
+        </View>
+      )
+    }
+    return <></>
+  }, [isDesktop, pal, store, hasNew])
+
+  return (
+    <View testID={testID} style={s.h100pct}>
+      <Feed
+        testID={testID ? `${testID}-feed` : undefined}
+        key="default"
+        feed={feed}
+        scrollElRef={scrollElRef}
+        onPressTryAgain={onPressTryAgain}
+        onScroll={onMainScroll}
+        scrollEventThrottle={100}
+        renderEmptyState={renderEmptyState}
+        renderEndOfFeed={renderEndOfFeed}
+        ListHeaderComponent={ListHeaderComponent}
+        headerOffset={headerOffset}
+      />
+      {(isScrolledDown || hasNew) && (
+        <LoadLatestBtn
+          onPress={onPressLoadLatest}
+          label="Load new posts"
+          showIndicator={hasNew}
+        />
+      )}
+      <FAB
+        testID="composeFAB"
+        onPress={onPressCompose}
+        icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
+        accessibilityRole="button"
+        accessibilityLabel="New post"
+        accessibilityHint=""
+      />
+    </View>
+  )
+})
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
index 553a4a2e7..7c7ad0616 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
@@ -1,9 +1,8 @@
-import React, {MutableRefObject, useState} from 'react'
+import React, {useState} from 'react'
 
 import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
 import {Image} from 'expo-image'
 import Animated, {
-  measure,
   runOnJS,
   useAnimatedRef,
   useAnimatedStyle,
@@ -12,11 +11,7 @@ import Animated, {
   withDecay,
   withSpring,
 } from 'react-native-reanimated'
-import {
-  GestureDetector,
-  Gesture,
-  GestureType,
-} from 'react-native-gesture-handler'
+import {GestureDetector, Gesture} from 'react-native-gesture-handler'
 import useImageDimensions from '../../hooks/useImageDimensions'
 import {
   createTransform,
@@ -39,16 +34,16 @@ const initialTransform = createTransform()
 type Props = {
   imageSrc: ImageSource
   onRequestClose: () => void
+  onTap: () => void
   onZoom: (isZoomed: boolean) => void
-  pinchGestureRef: MutableRefObject<GestureType | undefined>
   isScrollViewBeingDragged: boolean
 }
 const ImageItem = ({
   imageSrc,
+  onTap,
   onZoom,
   onRequestClose,
   isScrollViewBeingDragged,
-  pinchGestureRef,
 }: Props) => {
   const [isScaled, setIsScaled] = useState(false)
   const [isLoaded, setIsLoaded] = useState(false)
@@ -140,28 +135,7 @@ const ImageItem = ({
     return [dx, dy]
   }
 
-  // This is a hack.
-  // We need to disallow any gestures (and let the native parent scroll view scroll) while you're scrolling it.
-  // However, there is no great reliable way to coordinate this yet in RGNH.
-  // This "fake" manual gesture handler whenever you're trying to touch something while the parent scrollview is not at rest.
-  const consumeHScroll = Gesture.Manual().onTouchesDown((e, manager) => {
-    if (isScrollViewBeingDragged) {
-      // Steal the gesture (and do nothing, so native ScrollView does its thing).
-      manager.activate()
-      return
-    }
-    const measurement = measure(containerRef)
-    if (!measurement || measurement.pageX !== 0) {
-      // Steal the gesture (and do nothing, so native ScrollView does its thing).
-      manager.activate()
-      return
-    }
-    // Fail this "fake" gesture so that the gestures after it can proceed.
-    manager.fail()
-  })
-
   const pinch = Gesture.Pinch()
-    .withRef(pinchGestureRef)
     .onStart(e => {
       pinchOrigin.value = {
         x: e.focalX - SCREEN.width / 2,
@@ -255,6 +229,10 @@ const ImageItem = ({
       panTranslation.value = {x: 0, y: 0}
     })
 
+  const singleTap = Gesture.Tap().onEnd(() => {
+    runOnJS(onTap)()
+  })
+
   const doubleTap = Gesture.Tap()
     .numberOfTaps(2)
     .onEnd(e => {
@@ -318,22 +296,27 @@ const ImageItem = ({
       }
     })
 
+  const composedGesture = isScrollViewBeingDragged
+    ? // If the parent is not at rest, provide a no-op gesture.
+      Gesture.Manual()
+    : Gesture.Exclusive(
+        dismissSwipePan,
+        Gesture.Simultaneous(pinch, pan),
+        doubleTap,
+        singleTap,
+      )
+
   const isLoading = !isLoaded || !imageDimensions
   return (
     <Animated.View ref={containerRef} style={styles.container}>
       {isLoading && (
         <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
       )}
-      <GestureDetector
-        gesture={Gesture.Exclusive(
-          consumeHScroll,
-          dismissSwipePan,
-          Gesture.Simultaneous(pinch, pan),
-          doubleTap,
-        )}>
+      <GestureDetector gesture={composedGesture}>
         <AnimatedImage
-          source={imageSrc}
           contentFit="contain"
+          // NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
+          source={{uri: imageSrc.uri}}
           style={[styles.image, animatedStyle]}
           accessibilityLabel={imageSrc.alt}
           accessibilityHint=""
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
index 598b18ed2..f73f355ac 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
@@ -6,162 +6,162 @@
  *
  */
 
-import React, {MutableRefObject, useCallback, useRef, useState} from 'react'
+import React, {useState} from 'react'
 
-import {
-  Animated,
-  Dimensions,
-  ScrollView,
-  StyleSheet,
-  View,
-  NativeScrollEvent,
-  NativeSyntheticEvent,
-  NativeTouchEvent,
-  TouchableWithoutFeedback,
-} from 'react-native'
+import {Dimensions, StyleSheet} from 'react-native'
 import {Image} from 'expo-image'
-import {GestureType} from 'react-native-gesture-handler'
+import Animated, {
+  interpolate,
+  runOnJS,
+  useAnimatedRef,
+  useAnimatedScrollHandler,
+  useAnimatedStyle,
+  useSharedValue,
+} from 'react-native-reanimated'
+import {Gesture, GestureDetector} from 'react-native-gesture-handler'
 
 import useImageDimensions from '../../hooks/useImageDimensions'
 
 import {ImageSource, Dimensions as ImageDimensions} from '../../@types'
 import {ImageLoading} from './ImageLoading'
 
-const DOUBLE_TAP_DELAY = 300
 const SWIPE_CLOSE_OFFSET = 75
 const SWIPE_CLOSE_VELOCITY = 1
 const SCREEN = Dimensions.get('screen')
-const SCREEN_WIDTH = SCREEN.width
-const SCREEN_HEIGHT = SCREEN.height
-const MIN_ZOOM = 2
-const MAX_SCALE = 2
+const MAX_ORIGINAL_IMAGE_ZOOM = 2
+const MIN_DOUBLE_TAP_SCALE = 2
 
 type Props = {
   imageSrc: ImageSource
   onRequestClose: () => void
+  onTap: () => void
   onZoom: (scaled: boolean) => void
-  pinchGestureRef: MutableRefObject<GestureType>
   isScrollViewBeingDragged: boolean
 }
 
 const AnimatedImage = Animated.createAnimatedComponent(Image)
 
-let lastTapTS: number | null = null
-
-const ImageItem = ({imageSrc, onZoom, onRequestClose}: Props) => {
-  const scrollViewRef = useRef<ScrollView>(null)
+const ImageItem = ({imageSrc, onTap, onZoom, onRequestClose}: Props) => {
+  const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
+  const translationY = useSharedValue(0)
   const [loaded, setLoaded] = useState(false)
   const [scaled, setScaled] = useState(false)
   const imageDimensions = useImageDimensions(imageSrc)
-  const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
-  const [scrollValueY] = useState(() => new Animated.Value(0))
-  const maxScrollViewZoom = MAX_SCALE / (scale || 1)
+  const maxZoomScale = imageDimensions
+    ? (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
+    : 1
 
-  const imageOpacity = scrollValueY.interpolate({
-    inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
-    outputRange: [0.5, 1, 0.5],
+  const animatedStyle = useAnimatedStyle(() => {
+    return {
+      opacity: interpolate(
+        translationY.value,
+        [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
+        [0.5, 1, 0.5],
+      ),
+    }
   })
-  const imagesStyles = getImageStyles(imageDimensions, translate, scale || 1)
-  const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
 
-  const onScrollEndDrag = useCallback(
-    ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
-      const velocityY = nativeEvent?.velocity?.y ?? 0
-      const currentScaled = nativeEvent?.zoomScale > 1
-
-      onZoom(currentScaled)
-      setScaled(currentScaled)
-
-      if (!currentScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
-        onRequestClose()
+  const scrollHandler = useAnimatedScrollHandler({
+    onScroll(e) {
+      const nextIsScaled = e.zoomScale > 1
+      translationY.value = nextIsScaled ? 0 : e.contentOffset.y
+      if (scaled !== nextIsScaled) {
+        runOnJS(handleZoom)(nextIsScaled)
       }
     },
-    [onRequestClose, onZoom],
-  )
+    onEndDrag(e) {
+      const velocityY = e.velocity?.y ?? 0
+      const nextIsScaled = e.zoomScale > 1
+      if (scaled !== nextIsScaled) {
+        runOnJS(handleZoom)(nextIsScaled)
+      }
+      if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) {
+        runOnJS(onRequestClose)()
+      }
+    },
+  })
+
+  function handleZoom(nextIsScaled: boolean) {
+    onZoom(nextIsScaled)
+    setScaled(nextIsScaled)
+  }
 
-  const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
-    const offsetY = nativeEvent?.contentOffset?.y ?? 0
+  function handleDoubleTap(absoluteX: number, absoluteY: number) {
+    const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
+    let nextZoomRect = {
+      x: 0,
+      y: 0,
+      width: SCREEN.width,
+      height: SCREEN.height,
+    }
 
-    if (nativeEvent?.zoomScale > 1) {
-      return
+    const willZoom = !scaled
+    if (willZoom) {
+      nextZoomRect = getZoomRectAfterDoubleTap(
+        imageDimensions,
+        absoluteX,
+        absoluteY,
+      )
     }
 
-    scrollValueY.setValue(offsetY)
+    // @ts-ignore
+    scrollResponderRef?.scrollResponderZoomTo({
+      ...nextZoomRect, // This rect is in screen coordinates
+      animated: true,
+    })
   }
 
-  const handleDoubleTap = useCallback(
-    (event: NativeSyntheticEvent<NativeTouchEvent>) => {
-      const nowTS = new Date().getTime()
-      const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
-
-      if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) {
-        let nextZoomRect = {
-          x: 0,
-          y: 0,
-          width: SCREEN.width,
-          height: SCREEN.height,
-        }
+  const singleTap = Gesture.Tap().onEnd(() => {
+    runOnJS(onTap)()
+  })
 
-        const willZoom = !scaled
-        if (willZoom) {
-          const {pageX, pageY} = event.nativeEvent
-          nextZoomRect = getZoomRectAfterDoubleTap(
-            imageDimensions,
-            pageX,
-            pageY,
-          )
-        }
+  const doubleTap = Gesture.Tap()
+    .numberOfTaps(2)
+    .onEnd(e => {
+      const {absoluteX, absoluteY} = e
+      runOnJS(handleDoubleTap)(absoluteX, absoluteY)
+    })
 
-        // @ts-ignore
-        scrollResponderRef?.scrollResponderZoomTo({
-          ...nextZoomRect, // This rect is in screen coordinates
-          animated: true,
-        })
-      } else {
-        lastTapTS = nowTS
-      }
-    },
-    [imageDimensions, scaled],
-  )
+  const composedGesture = Gesture.Exclusive(doubleTap, singleTap)
 
   return (
-    <View>
-      <ScrollView
+    <GestureDetector gesture={composedGesture}>
+      <Animated.ScrollView
+        // @ts-ignore Something's up with the types here
         ref={scrollViewRef}
         style={styles.listItem}
         pinchGestureEnabled
         showsHorizontalScrollIndicator={false}
         showsVerticalScrollIndicator={false}
-        maximumZoomScale={maxScrollViewZoom}
+        maximumZoomScale={maxZoomScale}
         contentContainerStyle={styles.imageScrollContainer}
-        scrollEnabled={true}
-        onScroll={onScroll}
-        onScrollEndDrag={onScrollEndDrag}
-        scrollEventThrottle={1}>
+        onScroll={scrollHandler}>
         {(!loaded || !imageDimensions) && <ImageLoading />}
-        <TouchableWithoutFeedback
-          onPress={handleDoubleTap}
-          accessibilityRole="image"
+        <AnimatedImage
+          contentFit="contain"
+          // NOTE: Don't pass imageSrc={imageSrc} or MobX will break.
+          source={{uri: imageSrc.uri}}
+          style={[styles.image, animatedStyle]}
           accessibilityLabel={imageSrc.alt}
-          accessibilityHint="">
-          <AnimatedImage
-            source={imageSrc}
-            style={imageStylesWithOpacity}
-            onLoad={() => setLoaded(true)}
-          />
-        </TouchableWithoutFeedback>
-      </ScrollView>
-    </View>
+          accessibilityHint=""
+          onLoad={() => setLoaded(true)}
+        />
+      </Animated.ScrollView>
+    </GestureDetector>
   )
 }
 
 const styles = StyleSheet.create({
+  imageScrollContainer: {
+    height: SCREEN.height,
+  },
   listItem: {
-    width: SCREEN_WIDTH,
-    height: SCREEN_HEIGHT,
+    width: SCREEN.width,
+    height: SCREEN.height,
   },
-  imageScrollContainer: {
-    height: SCREEN_HEIGHT,
+  image: {
+    width: SCREEN.width,
+    height: SCREEN.height,
   },
 })
 
@@ -191,7 +191,7 @@ const getZoomRectAfterDoubleTap = (
   const zoom = Math.max(
     imageAspect / screenAspect,
     screenAspect / imageAspect,
-    MIN_ZOOM,
+    MIN_DOUBLE_TAP_SCALE,
   )
   // Unlike in the Android version, we don't constrain the *max* zoom level here.
   // Instead, this is done in the ScrollView props so that it constraints pinch too.
@@ -253,61 +253,4 @@ const getZoomRectAfterDoubleTap = (
   }
 }
 
-const getImageStyles = (
-  image: ImageDimensions | null,
-  translate: {readonly x: number; readonly y: number} | undefined,
-  scale?: number,
-) => {
-  if (!image?.width || !image?.height) {
-    return {width: 0, height: 0}
-  }
-  const transform = []
-  if (translate) {
-    transform.push({translateX: translate.x})
-    transform.push({translateY: translate.y})
-  }
-  if (scale) {
-    // @ts-ignore TODO - is scale incorrect? might need to remove -prf
-    transform.push({scale}, {perspective: new Animated.Value(1000)})
-  }
-  return {
-    width: image.width,
-    height: image.height,
-    transform,
-  }
-}
-
-const getImageTransform = (
-  image: ImageDimensions | null,
-  screen: ImageDimensions,
-) => {
-  if (!image?.width || !image?.height) {
-    return [] as const
-  }
-
-  const wScale = screen.width / image.width
-  const hScale = screen.height / image.height
-  const scale = Math.min(wScale, hScale)
-  const {x, y} = getImageTranslate(image, screen)
-
-  return [{x, y}, scale] as const
-}
-
-const getImageTranslate = (
-  image: ImageDimensions,
-  screen: ImageDimensions,
-): {x: number; y: number} => {
-  const getTranslateForAxis = (axis: 'x' | 'y'): number => {
-    const imageSize = axis === 'x' ? image.width : image.height
-    const screenSize = axis === 'x' ? screen.width : screen.height
-
-    return (screenSize - imageSize) / 2
-  }
-
-  return {
-    x: getTranslateForAxis('x'),
-    y: getTranslateForAxis('y'),
-  }
-}
-
 export default React.memo(ImageItem)
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
index 898b00c78..16688b820 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
@@ -1,15 +1,14 @@
 // default implementation fallback for web
 
-import React, {MutableRefObject} from 'react'
+import React from 'react'
 import {View} from 'react-native'
-import {GestureType} from 'react-native-gesture-handler'
 import {ImageSource} from '../../@types'
 
 type Props = {
   imageSrc: ImageSource
   onRequestClose: () => void
+  onTap: () => void
   onZoom: (scaled: boolean) => void
-  pinchGestureRef: MutableRefObject<GestureType | undefined>
   isScrollViewBeingDragged: boolean
 }
 
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts
index 7f0851af3..cb46fd0d9 100644
--- a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts
+++ b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts
@@ -39,29 +39,10 @@ const useImageDimensions = (image: ImageSource): Dimensions | null => {
   // eslint-disable-next-line @typescript-eslint/no-shadow
   const getImageDimensions = (image: ImageSource): Promise<Dimensions> => {
     return new Promise(resolve => {
-      if (typeof image === 'number') {
-        const cacheKey = `${image}`
-        let imageDimensions = imageDimensionsCache.get(cacheKey)
-
-        if (!imageDimensions) {
-          const {width, height} = Image.resolveAssetSource(image)
-          imageDimensions = {width, height}
-          imageDimensionsCache.set(cacheKey, imageDimensions)
-        }
-
-        resolve(imageDimensions)
-
-        return
-      }
-
-      // @ts-ignore
       if (image.uri) {
         const source = image as ImageURISource
-
         const cacheKey = source.uri as string
-
         const imageDimensions = imageDimensionsCache.get(cacheKey)
-
         if (imageDimensions) {
           resolve(imageDimensions)
         } else {
diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx
index bc2a8a448..b6835793d 100644
--- a/src/view/com/lightbox/ImageViewing/index.tsx
+++ b/src/view/com/lightbox/ImageViewing/index.tsx
@@ -8,121 +8,72 @@
 // Original code copied and simplified from the link below as the codebase is currently not maintained:
 // https://github.com/jobtoday/react-native-image-viewing
 
-import React, {
-  ComponentType,
-  createRef,
-  useCallback,
-  useRef,
-  useMemo,
-  useState,
-} from 'react'
-import {
-  Animated,
-  Dimensions,
-  NativeSyntheticEvent,
-  NativeScrollEvent,
-  StyleSheet,
-  View,
-  VirtualizedList,
-  ModalProps,
-  Platform,
-} from 'react-native'
-import {ModalsContainer} from '../../modals/Modal'
+import React, {ComponentType, useCallback, useMemo, useState} from 'react'
+import {StyleSheet, View, Platform} from 'react-native'
 
 import ImageItem from './components/ImageItem/ImageItem'
 import ImageDefaultHeader from './components/ImageDefaultHeader'
 
 import {ImageSource} from './@types'
-import {ScrollView, GestureType} from 'react-native-gesture-handler'
+import Animated, {useAnimatedStyle, withSpring} from 'react-native-reanimated'
 import {Edge, SafeAreaView} from 'react-native-safe-area-context'
+import PagerView from 'react-native-pager-view'
 
 type Props = {
   images: ImageSource[]
-  keyExtractor?: (imageSrc: ImageSource, index: number) => string
-  imageIndex: number
+  initialImageIndex: number
   visible: boolean
   onRequestClose: () => void
-  presentationStyle?: ModalProps['presentationStyle']
-  animationType?: ModalProps['animationType']
   backgroundColor?: string
   HeaderComponent?: ComponentType<{imageIndex: number}>
   FooterComponent?: ComponentType<{imageIndex: number}>
 }
 
 const DEFAULT_BG_COLOR = '#000'
-const SCREEN = Dimensions.get('screen')
-const SCREEN_WIDTH = SCREEN.width
-const INITIAL_POSITION = {x: 0, y: 0}
-const ANIMATION_CONFIG = {
-  duration: 200,
-  useNativeDriver: true,
-}
 
 function ImageViewing({
   images,
-  keyExtractor,
-  imageIndex,
+  initialImageIndex,
   visible,
   onRequestClose,
   backgroundColor = DEFAULT_BG_COLOR,
   HeaderComponent,
   FooterComponent,
 }: Props) {
-  const imageList = useRef<VirtualizedList<ImageSource>>(null)
   const [isScaled, setIsScaled] = useState(false)
   const [isDragging, setIsDragging] = useState(false)
-  const [opacity, setOpacity] = useState(1)
-  const [currentImageIndex, setImageIndex] = useState(imageIndex)
-  const [headerTranslate] = useState(
-    () => new Animated.ValueXY(INITIAL_POSITION),
-  )
-  const [footerTranslate] = useState(
-    () => new Animated.ValueXY(INITIAL_POSITION),
-  )
-
-  const toggleBarsVisible = (isVisible: boolean) => {
-    if (isVisible) {
-      Animated.parallel([
-        Animated.timing(headerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
-        Animated.timing(footerTranslate.y, {...ANIMATION_CONFIG, toValue: 0}),
-      ]).start()
-    } else {
-      Animated.parallel([
-        Animated.timing(headerTranslate.y, {
-          ...ANIMATION_CONFIG,
-          toValue: -300,
-        }),
-        Animated.timing(footerTranslate.y, {
-          ...ANIMATION_CONFIG,
-          toValue: 300,
-        }),
-      ]).start()
-    }
-  }
-
-  const onRequestCloseEnhanced = () => {
-    setOpacity(0)
-    onRequestClose()
-    setTimeout(() => setOpacity(1), 0)
-  }
-
-  const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
-    const {
-      nativeEvent: {
-        contentOffset: {x: scrollX},
+  const [imageIndex, setImageIndex] = useState(initialImageIndex)
+  const [showControls, setShowControls] = useState(true)
+
+  const animatedHeaderStyle = useAnimatedStyle(() => ({
+    pointerEvents: showControls ? 'auto' : 'none',
+    opacity: withClampedSpring(showControls ? 1 : 0),
+    transform: [
+      {
+        translateY: withClampedSpring(showControls ? 0 : -30),
+      },
+    ],
+  }))
+  const animatedFooterStyle = useAnimatedStyle(() => ({
+    pointerEvents: showControls ? 'auto' : 'none',
+    opacity: withClampedSpring(showControls ? 1 : 0),
+    transform: [
+      {
+        translateY: withClampedSpring(showControls ? 0 : 30),
       },
-    } = event
+    ],
+  }))
 
-    if (SCREEN.width) {
-      const nextIndex = Math.round(scrollX / SCREEN.width)
-      setImageIndex(nextIndex < 0 ? 0 : nextIndex)
-    }
-  }
+  const onTap = useCallback(() => {
+    setShowControls(show => !show)
+  }, [])
 
-  const onZoom = (nextIsScaled: boolean) => {
-    toggleBarsVisible(!nextIsScaled)
-    setIsScaled(false)
-  }
+  const onZoom = useCallback((nextIsScaled: boolean) => {
+    setIsScaled(nextIsScaled)
+    if (nextIsScaled) {
+      setShowControls(false)
+    }
+  }, [])
 
   const edges = useMemo(() => {
     if (Platform.OS === 'android') {
@@ -131,100 +82,54 @@ function ImageViewing({
     return ['left', 'right'] satisfies Edge[] // iOS, so no top/bottom safe area
   }, [])
 
-  const onLayout = useCallback(() => {
-    if (imageIndex) {
-      imageList.current?.scrollToIndex({index: imageIndex, animated: false})
-    }
-  }, [imageList, imageIndex])
-
-  // This is a hack.
-  // RNGH doesn't have an easy way to express that pinch of individual items
-  // should "steal" all pinches from the scroll view. So we're keeping a ref
-  // to all pinch gestures so that we may give them to <ScrollView waitFor={...}>.
-  const [pinchGestureRefs] = useState(new Map())
-  for (let imageSrc of images) {
-    if (!pinchGestureRefs.get(imageSrc)) {
-      pinchGestureRefs.set(imageSrc, createRef<GestureType | undefined>())
-    }
-  }
-
   if (!visible) {
     return null
   }
 
-  const headerTransform = headerTranslate.getTranslateTransform()
-  const footerTransform = footerTranslate.getTranslateTransform()
   return (
     <SafeAreaView
       style={styles.screen}
-      onLayout={onLayout}
       edges={edges}
       aria-modal
       accessibilityViewIsModal>
-      <ModalsContainer />
-      <View style={[styles.container, {opacity, backgroundColor}]}>
-        <Animated.View style={[styles.header, {transform: headerTransform}]}>
+      <View style={[styles.container, {backgroundColor}]}>
+        <Animated.View style={[styles.header, animatedHeaderStyle]}>
           {typeof HeaderComponent !== 'undefined' ? (
             React.createElement(HeaderComponent, {
-              imageIndex: currentImageIndex,
+              imageIndex,
             })
           ) : (
-            <ImageDefaultHeader onRequestClose={onRequestCloseEnhanced} />
+            <ImageDefaultHeader onRequestClose={onRequestClose} />
           )}
         </Animated.View>
-        <VirtualizedList
-          ref={imageList}
-          data={images}
-          horizontal
-          pagingEnabled
-          scrollEnabled={!isScaled || isDragging}
-          showsHorizontalScrollIndicator={false}
-          showsVerticalScrollIndicator={false}
-          getItem={(_, index) => images[index]}
-          getItemCount={() => images.length}
-          getItemLayout={(_, index) => ({
-            length: SCREEN_WIDTH,
-            offset: SCREEN_WIDTH * index,
-            index,
-          })}
-          renderItem={({item: imageSrc}) => (
-            <ImageItem
-              onZoom={onZoom}
-              imageSrc={imageSrc}
-              onRequestClose={onRequestCloseEnhanced}
-              pinchGestureRef={pinchGestureRefs.get(imageSrc)}
-              isScrollViewBeingDragged={isDragging}
-            />
-          )}
-          renderScrollComponent={props => (
-            <ScrollView
-              {...props}
-              waitFor={Array.from(pinchGestureRefs.values())}
-            />
-          )}
-          onScrollBeginDrag={() => {
-            setIsDragging(true)
-          }}
-          onScrollEndDrag={() => {
-            setIsDragging(false)
-          }}
-          onMomentumScrollEnd={e => {
+        <PagerView
+          scrollEnabled={!isScaled}
+          initialPage={initialImageIndex}
+          onPageSelected={e => {
+            setImageIndex(e.nativeEvent.position)
             setIsScaled(false)
-            onScroll(e)
           }}
-          //@ts-ignore
-          keyExtractor={(imageSrc, index) =>
-            keyExtractor
-              ? keyExtractor(imageSrc, index)
-              : typeof imageSrc === 'number'
-              ? `${imageSrc}`
-              : imageSrc.uri
-          }
-        />
+          onPageScrollStateChanged={e => {
+            setIsDragging(e.nativeEvent.pageScrollState !== 'idle')
+          }}
+          overdrag={true}
+          style={styles.pager}>
+          {images.map(imageSrc => (
+            <View key={imageSrc.uri}>
+              <ImageItem
+                onTap={onTap}
+                onZoom={onZoom}
+                imageSrc={imageSrc}
+                onRequestClose={onRequestClose}
+                isScrollViewBeingDragged={isDragging}
+              />
+            </View>
+          ))}
+        </PagerView>
         {typeof FooterComponent !== 'undefined' && (
-          <Animated.View style={[styles.footer, {transform: footerTransform}]}>
+          <Animated.View style={[styles.footer, animatedFooterStyle]}>
             {React.createElement(FooterComponent, {
-              imageIndex: currentImageIndex,
+              imageIndex,
             })}
           </Animated.View>
         )}
@@ -236,11 +141,18 @@ function ImageViewing({
 const styles = StyleSheet.create({
   screen: {
     position: 'absolute',
+    top: 0,
+    left: 0,
+    bottom: 0,
+    right: 0,
   },
   container: {
     flex: 1,
     backgroundColor: '#000',
   },
+  pager: {
+    flex: 1,
+  },
   header: {
     position: 'absolute',
     width: '100%',
@@ -257,7 +169,12 @@ const styles = StyleSheet.create({
 })
 
 const EnhancedImageViewing = (props: Props) => (
-  <ImageViewing key={props.imageIndex} {...props} />
+  <ImageViewing key={props.initialImageIndex} {...props} />
 )
 
+function withClampedSpring(value: any) {
+  'worklet'
+  return withSpring(value, {overshootClamping: true, stiffness: 300})
+}
+
 export default EnhancedImageViewing
diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx
index ad66dce32..92c30f491 100644
--- a/src/view/com/lightbox/Lightbox.tsx
+++ b/src/view/com/lightbox/Lightbox.tsx
@@ -26,7 +26,7 @@ export const Lightbox = observer(function Lightbox() {
     return (
       <ImageView
         images={[{uri: opts.profileView.avatar || ''}]}
-        imageIndex={0}
+        initialImageIndex={0}
         visible
         onRequestClose={onClose}
         FooterComponent={LightboxFooter}
@@ -37,7 +37,7 @@ export const Lightbox = observer(function Lightbox() {
     return (
       <ImageView
         images={opts.images.map(img => ({...img}))}
-        imageIndex={opts.index}
+        initialImageIndex={opts.index}
         visible
         onRequestClose={onClose}
         FooterComponent={LightboxFooter}
diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx
index c92dabdca..012570556 100644
--- a/src/view/com/modals/ChangeEmail.tsx
+++ b/src/view/com/modals/ChangeEmail.tsx
@@ -1,11 +1,5 @@
 import React, {useState} from 'react'
-import {
-  ActivityIndicator,
-  KeyboardAvoidingView,
-  SafeAreaView,
-  StyleSheet,
-  View,
-} from 'react-native'
+import {ActivityIndicator, SafeAreaView, StyleSheet, View} from 'react-native'
 import {ScrollView, TextInput} from './util'
 import {observer} from 'mobx-react-lite'
 import {Text} from '../util/text/Text'
@@ -101,142 +95,134 @@ export const Component = observer(function Component({}: {}) {
   }
 
   return (
-    <KeyboardAvoidingView
-      behavior="padding"
-      style={[pal.view, styles.container]}>
-      <SafeAreaView style={s.flex1}>
-        <ScrollView
-          testID="changeEmailModal"
-          style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
-          <View style={styles.titleSection}>
-            <Text type="title-lg" style={[pal.text, styles.title]}>
-              {stage === Stages.InputEmail ? 'Change Your Email' : ''}
-              {stage === Stages.ConfirmCode ? 'Security Step Required' : ''}
-              {stage === Stages.Done ? 'Email Updated' : ''}
-            </Text>
-          </View>
-
-          <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
-            {stage === Stages.InputEmail ? (
-              <>Enter your new email address below.</>
-            ) : stage === Stages.ConfirmCode ? (
-              <>
-                An email has been sent to your previous address,{' '}
-                {store.session.currentSession?.email || ''}. It includes a
-                confirmation code which you can enter below.
-              </>
-            ) : (
-              <>
-                Your email has been updated but not verified. As a next step,
-                please verify your new email.
-              </>
-            )}
+    <SafeAreaView style={[pal.view, s.flex1]}>
+      <ScrollView
+        testID="changeEmailModal"
+        style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
+        <View style={styles.titleSection}>
+          <Text type="title-lg" style={[pal.text, styles.title]}>
+            {stage === Stages.InputEmail ? 'Change Your Email' : ''}
+            {stage === Stages.ConfirmCode ? 'Security Step Required' : ''}
+            {stage === Stages.Done ? 'Email Updated' : ''}
           </Text>
+        </View>
 
-          {stage === Stages.InputEmail && (
-            <TextInput
-              testID="emailInput"
-              style={[styles.textInput, pal.border, pal.text]}
-              placeholder="alice@mail.com"
-              placeholderTextColor={pal.colors.textLight}
-              value={email}
-              onChangeText={setEmail}
-              accessible={true}
-              accessibilityLabel="Email"
-              accessibilityHint=""
-              autoCapitalize="none"
-              autoComplete="email"
-              autoCorrect={false}
-            />
-          )}
-          {stage === Stages.ConfirmCode && (
-            <TextInput
-              testID="confirmCodeInput"
-              style={[styles.textInput, pal.border, pal.text]}
-              placeholder="XXXXX-XXXXX"
-              placeholderTextColor={pal.colors.textLight}
-              value={confirmationCode}
-              onChangeText={setConfirmationCode}
-              accessible={true}
-              accessibilityLabel="Confirmation code"
-              accessibilityHint=""
-              autoCapitalize="none"
-              autoComplete="off"
-              autoCorrect={false}
-            />
+        <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
+          {stage === Stages.InputEmail ? (
+            <>Enter your new email address below.</>
+          ) : stage === Stages.ConfirmCode ? (
+            <>
+              An email has been sent to your previous address,{' '}
+              {store.session.currentSession?.email || ''}. It includes a
+              confirmation code which you can enter below.
+            </>
+          ) : (
+            <>
+              Your email has been updated but not verified. As a next step,
+              please verify your new email.
+            </>
           )}
+        </Text>
 
-          {error ? (
-            <ErrorMessage message={error} style={styles.error} />
-          ) : undefined}
+        {stage === Stages.InputEmail && (
+          <TextInput
+            testID="emailInput"
+            style={[styles.textInput, pal.border, pal.text]}
+            placeholder="alice@mail.com"
+            placeholderTextColor={pal.colors.textLight}
+            value={email}
+            onChangeText={setEmail}
+            accessible={true}
+            accessibilityLabel="Email"
+            accessibilityHint=""
+            autoCapitalize="none"
+            autoComplete="email"
+            autoCorrect={false}
+          />
+        )}
+        {stage === Stages.ConfirmCode && (
+          <TextInput
+            testID="confirmCodeInput"
+            style={[styles.textInput, pal.border, pal.text]}
+            placeholder="XXXXX-XXXXX"
+            placeholderTextColor={pal.colors.textLight}
+            value={confirmationCode}
+            onChangeText={setConfirmationCode}
+            accessible={true}
+            accessibilityLabel="Confirmation code"
+            accessibilityHint=""
+            autoCapitalize="none"
+            autoComplete="off"
+            autoCorrect={false}
+          />
+        )}
 
-          <View style={[styles.btnContainer]}>
-            {isProcessing ? (
-              <View style={styles.btn}>
-                <ActivityIndicator color="#fff" />
-              </View>
-            ) : (
-              <View style={{gap: 6}}>
-                {stage === Stages.InputEmail && (
-                  <Button
-                    testID="requestChangeBtn"
-                    type="primary"
-                    onPress={onRequestChange}
-                    accessibilityLabel="Request Change"
-                    accessibilityHint=""
-                    label="Request Change"
-                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
-                    labelStyle={[s.f18]}
-                  />
-                )}
-                {stage === Stages.ConfirmCode && (
-                  <Button
-                    testID="confirmBtn"
-                    type="primary"
-                    onPress={onConfirm}
-                    accessibilityLabel="Confirm Change"
-                    accessibilityHint=""
-                    label="Confirm Change"
-                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
-                    labelStyle={[s.f18]}
-                  />
-                )}
-                {stage === Stages.Done && (
-                  <Button
-                    testID="verifyBtn"
-                    type="primary"
-                    onPress={onVerify}
-                    accessibilityLabel="Verify New Email"
-                    accessibilityHint=""
-                    label="Verify New Email"
-                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
-                    labelStyle={[s.f18]}
-                  />
-                )}
+        {error ? (
+          <ErrorMessage message={error} style={styles.error} />
+        ) : undefined}
+
+        <View style={[styles.btnContainer]}>
+          {isProcessing ? (
+            <View style={styles.btn}>
+              <ActivityIndicator color="#fff" />
+            </View>
+          ) : (
+            <View style={{gap: 6}}>
+              {stage === Stages.InputEmail && (
+                <Button
+                  testID="requestChangeBtn"
+                  type="primary"
+                  onPress={onRequestChange}
+                  accessibilityLabel="Request Change"
+                  accessibilityHint=""
+                  label="Request Change"
+                  labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                  labelStyle={[s.f18]}
+                />
+              )}
+              {stage === Stages.ConfirmCode && (
                 <Button
-                  testID="cancelBtn"
-                  type="default"
-                  onPress={() => store.shell.closeModal()}
-                  accessibilityLabel="Cancel"
+                  testID="confirmBtn"
+                  type="primary"
+                  onPress={onConfirm}
+                  accessibilityLabel="Confirm Change"
                   accessibilityHint=""
-                  label="Cancel"
+                  label="Confirm Change"
                   labelContainerStyle={{justifyContent: 'center', padding: 4}}
                   labelStyle={[s.f18]}
                 />
-              </View>
-            )}
-          </View>
-        </ScrollView>
-      </SafeAreaView>
-    </KeyboardAvoidingView>
+              )}
+              {stage === Stages.Done && (
+                <Button
+                  testID="verifyBtn"
+                  type="primary"
+                  onPress={onVerify}
+                  accessibilityLabel="Verify New Email"
+                  accessibilityHint=""
+                  label="Verify New Email"
+                  labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                  labelStyle={[s.f18]}
+                />
+              )}
+              <Button
+                testID="cancelBtn"
+                type="default"
+                onPress={() => store.shell.closeModal()}
+                accessibilityLabel="Cancel"
+                accessibilityHint=""
+                label="Cancel"
+                labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                labelStyle={[s.f18]}
+              />
+            </View>
+          )}
+        </View>
+      </ScrollView>
+    </SafeAreaView>
   )
 })
 
 const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-    paddingBottom: isWeb ? 0 : 40,
-  },
   titleSection: {
     paddingTop: isWeb ? 0 : 4,
     paddingBottom: isWeb ? 14 : 10,
diff --git a/src/view/com/modals/CreateOrEditMuteList.tsx b/src/view/com/modals/CreateOrEditMuteList.tsx
index 3f3cfc5f0..4a440afeb 100644
--- a/src/view/com/modals/CreateOrEditMuteList.tsx
+++ b/src/view/com/modals/CreateOrEditMuteList.tsx
@@ -18,7 +18,7 @@ import {ListModel} from 'state/models/content/list'
 import {s, colors, gradients} from 'lib/styles'
 import {enforceLen} from 'lib/strings/helpers'
 import {compressIfNeeded} from 'lib/media/manip'
-import {UserAvatar} from '../util/UserAvatar'
+import {EditableUserAvatar} from '../util/UserAvatar'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
 import {useAnalytics} from 'lib/analytics/analytics'
@@ -148,7 +148,7 @@ export function Component({
         )}
         <Text style={[styles.label, pal.text]}>List Avatar</Text>
         <View style={[styles.avi, {borderColor: pal.colors.background}]}>
-          <UserAvatar
+          <EditableUserAvatar
             type="list"
             size={80}
             avatar={avatar}
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index 620aad9fc..58d0857ad 100644
--- a/src/view/com/modals/EditProfile.tsx
+++ b/src/view/com/modals/EditProfile.tsx
@@ -20,7 +20,7 @@ import {enforceLen} from 'lib/strings/helpers'
 import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants'
 import {compressIfNeeded} from 'lib/media/manip'
 import {UserBanner} from '../util/UserBanner'
-import {UserAvatar} from '../util/UserAvatar'
+import {EditableUserAvatar} from '../util/UserAvatar'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
 import {useAnalytics} from 'lib/analytics/analytics'
@@ -153,7 +153,7 @@ export function Component({
             onSelectNewBanner={onSelectNewBanner}
           />
           <View style={[styles.avi, {borderColor: pal.colors.background}]}>
-            <UserAvatar
+            <EditableUserAvatar
               size={80}
               avatar={userAvatar}
               onSelectNewAvatar={onSelectNewAvatar}
diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx
index 0cb0c56aa..09cfd4de7 100644
--- a/src/view/com/modals/InviteCodes.tsx
+++ b/src/view/com/modals/InviteCodes.tsx
@@ -26,33 +26,6 @@ export function Component({}: {}) {
     store.shell.closeModal()
   }, [store])
 
-  if (store.me.invites === null) {
-    return (
-      <View style={[styles.container, pal.view]} testID="inviteCodesModal">
-        <Text type="title-xl" style={[styles.title, pal.text]}>
-          Error
-        </Text>
-        <Text type="lg" style={[styles.description, pal.text]}>
-          An error occurred while loading invite codes.
-        </Text>
-        <View style={styles.flex1} />
-        <View
-          style={[
-            styles.btnContainer,
-            isTabletOrDesktop && styles.btnContainerDesktop,
-          ]}>
-          <Button
-            type="primary"
-            label="Done"
-            style={styles.btn}
-            labelStyle={styles.btnLabel}
-            onPress={onClose}
-          />
-        </View>
-      </View>
-    )
-  }
-
   if (store.me.invites.length === 0) {
     return (
       <View style={[styles.container, pal.view]} testID="inviteCodesModal">
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 4f3f424a3..1fe1299d7 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -1,11 +1,12 @@
 import React, {useRef, useEffect} from 'react'
 import {StyleSheet} from 'react-native'
-import {SafeAreaView} from 'react-native-safe-area-context'
+import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'
 import {observer} from 'mobx-react-lite'
 import BottomSheet from '@gorhom/bottom-sheet'
 import {useStores} from 'state/index'
 import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
 import {usePalette} from 'lib/hooks/usePalette'
+import {timeout} from 'lib/async/timeout'
 import {navigate} from '../../../Navigation'
 import once from 'lodash.once'
 
@@ -36,11 +37,13 @@ import * as SwitchAccountModal from './SwitchAccount'
 import * as LinkWarningModal from './LinkWarning'
 
 const DEFAULT_SNAPPOINTS = ['90%']
+const HANDLE_HEIGHT = 24
 
 export const ModalsContainer = observer(function ModalsContainer() {
   const store = useStores()
   const bottomSheetRef = useRef<BottomSheet>(null)
   const pal = usePalette('default')
+  const safeAreaInsets = useSafeAreaInsets()
 
   const activeModal =
     store.shell.activeModals[store.shell.activeModals.length - 1]
@@ -53,12 +56,16 @@ export const ModalsContainer = observer(function ModalsContainer() {
       navigateOnce('Profile', {name: activeModal.did})
     }
   }
-  const onBottomSheetChange = (snapPoint: number) => {
+  const onBottomSheetChange = async (snapPoint: number) => {
     if (snapPoint === -1) {
       store.shell.closeModal()
     } else if (activeModal?.name === 'profile-preview' && snapPoint === 1) {
-      // ensure we navigate to Profile and close the modal
-      navigateOnce('Profile', {name: activeModal.did})
+      await navigateOnce('Profile', {name: activeModal.did})
+      // There is no particular callback for when the view has actually been presented.
+      // This delay gives us a decent chance the navigation has flushed *and* images have loaded.
+      // It's acceptable because the data is already being fetched + it usually takes longer anyway.
+      // TODO: Figure out why avatar/cover don't always show instantly from cache.
+      await timeout(200)
       store.shell.closeModal()
     }
   }
@@ -75,6 +82,7 @@ export const ModalsContainer = observer(function ModalsContainer() {
     }
   }, [store.shell.isModalActive, bottomSheetRef, activeModal?.name])
 
+  let needsSafeTopInset = false
   let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
   let element
   if (activeModal?.name === 'confirm') {
@@ -86,6 +94,7 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'profile-preview') {
     snapPoints = ProfilePreviewModal.snapPoints
     element = <ProfilePreviewModal.Component {...activeModal} />
+    needsSafeTopInset = true // Need to align with the target profile screen.
   } else if (activeModal?.name === 'server-input') {
     snapPoints = ServerInputModal.snapPoints
     element = <ServerInputModal.Component {...activeModal} />
@@ -164,10 +173,13 @@ export const ModalsContainer = observer(function ModalsContainer() {
     )
   }
 
+  const topInset = needsSafeTopInset ? safeAreaInsets.top - HANDLE_HEIGHT : 0
   return (
     <BottomSheet
       ref={bottomSheetRef}
       snapPoints={snapPoints}
+      topInset={topInset}
+      handleHeight={HANDLE_HEIGHT}
       index={store.shell.isModalActive ? 0 : -1}
       enablePanDownToClose
       android_keyboardInputMode="adjustResize"
diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx
index 225a3972b..dad02aa5e 100644
--- a/src/view/com/modals/ProfilePreview.tsx
+++ b/src/view/com/modals/ProfilePreview.tsx
@@ -9,7 +9,6 @@ import {useAnalytics} from 'lib/analytics/analytics'
 import {ProfileHeader} from '../profile/ProfileHeader'
 import {InfoCircleIcon} from 'lib/icons'
 import {useNavigationState} from '@react-navigation/native'
-import {isIOS} from 'platform/detection'
 import {s} from 'lib/styles'
 
 export const snapPoints = [520, '100%']
@@ -36,11 +35,7 @@ export const Component = observer(function ProfilePreviewImpl({
 
   return (
     <View testID="profilePreview" style={[pal.view, s.flex1]}>
-      <View
-        style={[
-          styles.headerWrapper,
-          isLoading && isIOS && styles.headerPositionAdjust,
-        ]}>
+      <View style={[styles.headerWrapper]}>
         <ProfileHeader
           view={model}
           hideBackButton
@@ -70,10 +65,6 @@ const styles = StyleSheet.create({
   headerWrapper: {
     height: 440,
   },
-  headerPositionAdjust: {
-    // HACK align the header for the profilescreen transition -prf
-    paddingTop: 23,
-  },
   hintWrapper: {
     height: 80,
   },
diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx
index 0a626a4ef..9fe8811b0 100644
--- a/src/view/com/modals/VerifyEmail.tsx
+++ b/src/view/com/modals/VerifyEmail.tsx
@@ -1,7 +1,6 @@
 import React, {useState} from 'react'
 import {
   ActivityIndicator,
-  KeyboardAvoidingView,
   Pressable,
   SafeAreaView,
   StyleSheet,
@@ -82,169 +81,163 @@ export const Component = observer(function Component({
   }
 
   return (
-    <KeyboardAvoidingView
-      behavior="padding"
-      style={[pal.view, styles.container]}>
-      <SafeAreaView style={s.flex1}>
-        <ScrollView
-          testID="verifyEmailModal"
-          style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
-          {stage === Stages.Reminder && <ReminderIllustration />}
-          <View style={styles.titleSection}>
-            <Text type="title-lg" style={[pal.text, styles.title]}>
-              {stage === Stages.Reminder ? 'Please Verify Your Email' : ''}
-              {stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''}
-              {stage === Stages.Email ? 'Verify Your Email' : ''}
-            </Text>
-          </View>
-
-          <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
-            {stage === Stages.Reminder ? (
-              <>
-                Your email has not yet been verified. This is an important
-                security step which we recommend.
-              </>
-            ) : stage === Stages.Email ? (
-              <>
-                This is important in case you ever need to change your email or
-                reset your password.
-              </>
-            ) : stage === Stages.ConfirmCode ? (
-              <>
-                An email has been sent to{' '}
-                {store.session.currentSession?.email || ''}. It includes a
-                confirmation code which you can enter below.
-              </>
-            ) : (
-              ''
-            )}
+    <SafeAreaView style={[pal.view, s.flex1]}>
+      <ScrollView
+        testID="verifyEmailModal"
+        style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
+        {stage === Stages.Reminder && <ReminderIllustration />}
+        <View style={styles.titleSection}>
+          <Text type="title-lg" style={[pal.text, styles.title]}>
+            {stage === Stages.Reminder ? 'Please Verify Your Email' : ''}
+            {stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''}
+            {stage === Stages.Email ? 'Verify Your Email' : ''}
           </Text>
+        </View>
 
-          {stage === Stages.Email ? (
+        <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
+          {stage === Stages.Reminder ? (
             <>
-              <View style={styles.emailContainer}>
-                <FontAwesomeIcon
-                  icon="envelope"
-                  color={pal.colors.text}
-                  size={16}
-                />
-                <Text
-                  type="xl-medium"
-                  style={[pal.text, s.flex1, {minWidth: 0}]}>
-                  {store.session.currentSession?.email || ''}
-                </Text>
-              </View>
-              <Pressable
-                accessibilityRole="link"
-                accessibilityLabel="Change my email"
-                accessibilityHint=""
-                onPress={onEmailIncorrect}
-                style={styles.changeEmailLink}>
-                <Text type="lg" style={pal.link}>
-                  Change
-                </Text>
-              </Pressable>
+              Your email has not yet been verified. This is an important
+              security step which we recommend.
+            </>
+          ) : stage === Stages.Email ? (
+            <>
+              This is important in case you ever need to change your email or
+              reset your password.
             </>
           ) : stage === Stages.ConfirmCode ? (
-            <TextInput
-              testID="confirmCodeInput"
-              style={[styles.textInput, pal.border, pal.text]}
-              placeholder="XXXXX-XXXXX"
-              placeholderTextColor={pal.colors.textLight}
-              value={confirmationCode}
-              onChangeText={setConfirmationCode}
-              accessible={true}
-              accessibilityLabel="Confirmation code"
+            <>
+              An email has been sent to{' '}
+              {store.session.currentSession?.email || ''}. It includes a
+              confirmation code which you can enter below.
+            </>
+          ) : (
+            ''
+          )}
+        </Text>
+
+        {stage === Stages.Email ? (
+          <>
+            <View style={styles.emailContainer}>
+              <FontAwesomeIcon
+                icon="envelope"
+                color={pal.colors.text}
+                size={16}
+              />
+              <Text type="xl-medium" style={[pal.text, s.flex1, {minWidth: 0}]}>
+                {store.session.currentSession?.email || ''}
+              </Text>
+            </View>
+            <Pressable
+              accessibilityRole="link"
+              accessibilityLabel="Change my email"
               accessibilityHint=""
-              autoCapitalize="none"
-              autoComplete="off"
-              autoCorrect={false}
-            />
-          ) : undefined}
+              onPress={onEmailIncorrect}
+              style={styles.changeEmailLink}>
+              <Text type="lg" style={pal.link}>
+                Change
+              </Text>
+            </Pressable>
+          </>
+        ) : stage === Stages.ConfirmCode ? (
+          <TextInput
+            testID="confirmCodeInput"
+            style={[styles.textInput, pal.border, pal.text]}
+            placeholder="XXXXX-XXXXX"
+            placeholderTextColor={pal.colors.textLight}
+            value={confirmationCode}
+            onChangeText={setConfirmationCode}
+            accessible={true}
+            accessibilityLabel="Confirmation code"
+            accessibilityHint=""
+            autoCapitalize="none"
+            autoComplete="off"
+            autoCorrect={false}
+          />
+        ) : undefined}
 
-          {error ? (
-            <ErrorMessage message={error} style={styles.error} />
-          ) : undefined}
+        {error ? (
+          <ErrorMessage message={error} style={styles.error} />
+        ) : undefined}
 
-          <View style={[styles.btnContainer]}>
-            {isProcessing ? (
-              <View style={styles.btn}>
-                <ActivityIndicator color="#fff" />
-              </View>
-            ) : (
-              <View style={{gap: 6}}>
-                {stage === Stages.Reminder && (
+        <View style={[styles.btnContainer]}>
+          {isProcessing ? (
+            <View style={styles.btn}>
+              <ActivityIndicator color="#fff" />
+            </View>
+          ) : (
+            <View style={{gap: 6}}>
+              {stage === Stages.Reminder && (
+                <Button
+                  testID="getStartedBtn"
+                  type="primary"
+                  onPress={() => setStage(Stages.Email)}
+                  accessibilityLabel="Get Started"
+                  accessibilityHint=""
+                  label="Get Started"
+                  labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                  labelStyle={[s.f18]}
+                />
+              )}
+              {stage === Stages.Email && (
+                <>
                   <Button
-                    testID="getStartedBtn"
+                    testID="sendEmailBtn"
                     type="primary"
-                    onPress={() => setStage(Stages.Email)}
-                    accessibilityLabel="Get Started"
+                    onPress={onSendEmail}
+                    accessibilityLabel="Send Confirmation Email"
                     accessibilityHint=""
-                    label="Get Started"
-                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                    label="Send Confirmation Email"
+                    labelContainerStyle={{
+                      justifyContent: 'center',
+                      padding: 4,
+                    }}
                     labelStyle={[s.f18]}
                   />
-                )}
-                {stage === Stages.Email && (
-                  <>
-                    <Button
-                      testID="sendEmailBtn"
-                      type="primary"
-                      onPress={onSendEmail}
-                      accessibilityLabel="Send Confirmation Email"
-                      accessibilityHint=""
-                      label="Send Confirmation Email"
-                      labelContainerStyle={{
-                        justifyContent: 'center',
-                        padding: 4,
-                      }}
-                      labelStyle={[s.f18]}
-                    />
-                    <Button
-                      testID="haveCodeBtn"
-                      type="default"
-                      accessibilityLabel="I have a code"
-                      accessibilityHint=""
-                      label="I have a confirmation code"
-                      labelContainerStyle={{
-                        justifyContent: 'center',
-                        padding: 4,
-                      }}
-                      labelStyle={[s.f18]}
-                      onPress={() => setStage(Stages.ConfirmCode)}
-                    />
-                  </>
-                )}
-                {stage === Stages.ConfirmCode && (
                   <Button
-                    testID="confirmBtn"
-                    type="primary"
-                    onPress={onConfirm}
-                    accessibilityLabel="Confirm"
+                    testID="haveCodeBtn"
+                    type="default"
+                    accessibilityLabel="I have a code"
                     accessibilityHint=""
-                    label="Confirm"
-                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                    label="I have a confirmation code"
+                    labelContainerStyle={{
+                      justifyContent: 'center',
+                      padding: 4,
+                    }}
                     labelStyle={[s.f18]}
+                    onPress={() => setStage(Stages.ConfirmCode)}
                   />
-                )}
+                </>
+              )}
+              {stage === Stages.ConfirmCode && (
                 <Button
-                  testID="cancelBtn"
-                  type="default"
-                  onPress={() => store.shell.closeModal()}
-                  accessibilityLabel={
-                    stage === Stages.Reminder ? 'Not right now' : 'Cancel'
-                  }
+                  testID="confirmBtn"
+                  type="primary"
+                  onPress={onConfirm}
+                  accessibilityLabel="Confirm"
                   accessibilityHint=""
-                  label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'}
+                  label="Confirm"
                   labelContainerStyle={{justifyContent: 'center', padding: 4}}
                   labelStyle={[s.f18]}
                 />
-              </View>
-            )}
-          </View>
-        </ScrollView>
-      </SafeAreaView>
-    </KeyboardAvoidingView>
+              )}
+              <Button
+                testID="cancelBtn"
+                type="default"
+                onPress={() => store.shell.closeModal()}
+                accessibilityLabel={
+                  stage === Stages.Reminder ? 'Not right now' : 'Cancel'
+                }
+                accessibilityHint=""
+                label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'}
+                labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                labelStyle={[s.f18]}
+              />
+            </View>
+          )}
+        </View>
+      </ScrollView>
+    </SafeAreaView>
   )
 })
 
@@ -274,10 +267,6 @@ function ReminderIllustration() {
 }
 
 const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-    paddingBottom: isWeb ? 0 : 40,
-  },
   titleSection: {
     paddingTop: isWeb ? 0 : 4,
     paddingBottom: isWeb ? 14 : 10,
diff --git a/src/view/com/modals/Waitlist.tsx b/src/view/com/modals/Waitlist.tsx
index 1104c0a39..0fb371fe4 100644
--- a/src/view/com/modals/Waitlist.tsx
+++ b/src/view/com/modals/Waitlist.tsx
@@ -77,6 +77,8 @@ export function Component({}: {}) {
           keyboardAppearance={theme.colorScheme}
           value={email}
           onChangeText={setEmail}
+          onSubmitEditing={onPressSignup}
+          enterKeyHint="done"
           accessible={true}
           accessibilityLabel="Email"
           accessibilityHint="Input your email to get on the Bluesky waitlist"
diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx
index c5959cf4c..8e35201d1 100644
--- a/src/view/com/modals/crop-image/CropImage.web.tsx
+++ b/src/view/com/modals/crop-image/CropImage.web.tsx
@@ -100,7 +100,7 @@ export function Component({
           accessibilityHint="Sets image aspect ratio to wide">
           <RectWideIcon
             size={24}
-            style={as === AspectRatio.Wide ? s.blue3 : undefined}
+            style={as === AspectRatio.Wide ? s.blue3 : pal.text}
           />
         </TouchableOpacity>
         <TouchableOpacity
@@ -110,7 +110,7 @@ export function Component({
           accessibilityHint="Sets image aspect ratio to tall">
           <RectTallIcon
             size={24}
-            style={as === AspectRatio.Tall ? s.blue3 : undefined}
+            style={as === AspectRatio.Tall ? s.blue3 : pal.text}
           />
         </TouchableOpacity>
         <TouchableOpacity
@@ -120,7 +120,7 @@ export function Component({
           accessibilityHint="Sets image aspect ratio to square">
           <SquareIcon
             size={24}
-            style={as === AspectRatio.Square ? s.blue3 : undefined}
+            style={as === AspectRatio.Square ? s.blue3 : pal.text}
           />
         </TouchableOpacity>
       </View>
diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx
index 02aa623cc..dc91bd296 100644
--- a/src/view/com/pager/FeedsTabBar.web.tsx
+++ b/src/view/com/pager/FeedsTabBar.web.tsx
@@ -1,13 +1,14 @@
 import React, {useMemo} from 'react'
-import {Animated, StyleSheet} from 'react-native'
+import {StyleSheet} from 'react-native'
+import Animated from 'react-native-reanimated'
 import {observer} from 'mobx-react-lite'
 import {TabBar} from 'view/com/pager/TabBar'
 import {RenderTabBarFnProps} from 'view/com/pager/Pager'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
+import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
 
 export const FeedsTabBar = observer(function FeedsTabBarImpl(
   props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
@@ -31,26 +32,12 @@ const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl(
     [store.me.savedFeeds.pinnedFeedNames],
   )
   const pal = usePalette('default')
-  const interp = useAnimatedValue(0)
-
-  React.useEffect(() => {
-    Animated.timing(interp, {
-      toValue: store.shell.minimalShellMode ? 1 : 0,
-      duration: 100,
-      useNativeDriver: true,
-      isInteraction: false,
-    }).start()
-  }, [interp, store.shell.minimalShellMode])
-  const transform = {
-    transform: [
-      {translateX: '-50%'},
-      {translateY: Animated.multiply(interp, -100)},
-    ],
-  }
+  const {headerMinimalShellTransform} = useMinimalShellMode()
 
   return (
     // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
-    <Animated.View style={[pal.view, styles.tabBar, transform]}>
+    <Animated.View
+      style={[pal.view, styles.tabBar, headerMinimalShellTransform]}>
       <TabBar
         key={items.join(',')}
         {...props}
@@ -65,7 +52,8 @@ const styles = StyleSheet.create({
   tabBar: {
     position: 'absolute',
     zIndex: 1,
-    left: '50%',
+    // @ts-ignore Web only -prf
+    left: 'calc(50% - 299px)',
     width: 598,
     top: 0,
     flexDirection: 'row',
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index e39e2dd68..d8579badc 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -1,11 +1,10 @@
 import React, {useMemo} from 'react'
-import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {TabBar} from 'view/com/pager/TabBar'
 import {RenderTabBarFnProps} from 'view/com/pager/Pager'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
@@ -13,27 +12,17 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
 import {s} from 'lib/styles'
 import {HITSLOP_10} from 'lib/constants'
+import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
+import Animated from 'react-native-reanimated'
 
 export const FeedsTabBar = observer(function FeedsTabBarImpl(
   props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
 ) {
   const store = useStores()
   const pal = usePalette('default')
-  const interp = useAnimatedValue(0)
-
-  React.useEffect(() => {
-    Animated.timing(interp, {
-      toValue: store.shell.minimalShellMode ? 1 : 0,
-      duration: 100,
-      useNativeDriver: true,
-      isInteraction: false,
-    }).start()
-  }, [interp, store.shell.minimalShellMode])
-  const transform = {
-    transform: [{translateY: Animated.multiply(interp, -100)}],
-  }
 
   const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3)
+  const {headerMinimalShellTransform} = useMinimalShellMode()
 
   const onPressAvi = React.useCallback(() => {
     store.shell.openDrawer()
@@ -44,8 +33,19 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
     [store.me.savedFeeds.pinnedFeedNames],
   )
 
+  const tabBarKey = useMemo(() => {
+    return items.join(',')
+  }, [items])
+
   return (
-    <Animated.View style={[pal.view, pal.border, styles.tabBar, transform]}>
+    <Animated.View
+      style={[
+        pal.view,
+        pal.border,
+        styles.tabBar,
+        headerMinimalShellTransform,
+        store.shell.minimalShellMode && styles.disabled,
+      ]}>
       <View style={[pal.view, styles.topBar]}>
         <View style={[pal.view]}>
           <TouchableOpacity
@@ -81,8 +81,11 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
         </View>
       </View>
       <TabBar
-        key={items.join(',')}
-        {...props}
+        key={tabBarKey}
+        onPressSelected={props.onPressSelected}
+        selectedPage={props.selectedPage}
+        onSelect={props.onSelect}
+        testID={props.testID}
         items={items}
         indicatorColor={pal.colors.link}
       />
@@ -113,4 +116,7 @@ const styles = StyleSheet.create({
   title: {
     fontSize: 21,
   },
+  disabled: {
+    pointerEvents: 'none',
+  },
 })
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index 319d28f95..8614bdf64 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -64,6 +64,7 @@ export function TabBar({
   )
 
   const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
+
   return (
     <View testID={testID} style={[pal.view, styles.outer]}>
       <DraggableScrollView
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index b095fe07b..74883f82a 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -45,7 +45,7 @@ export const Feed = observer(function Feed({
   onPressTryAgain?: () => void
   onScroll?: OnScrollCb
   scrollEventThrottle?: number
-  renderEmptyState?: () => JSX.Element
+  renderEmptyState: () => JSX.Element
   renderEndOfFeed?: () => JSX.Element
   testID?: string
   headerOffset?: number
@@ -96,7 +96,7 @@ export const Feed = observer(function Feed({
   }, [feed, track, setIsRefreshing])
 
   const onEndReached = React.useCallback(async () => {
-    if (!feed.hasLoaded) return
+    if (!feed.hasLoaded || !feed.hasMore) return
 
     track('Feed:onEndReached')
     try {
@@ -116,10 +116,7 @@ export const Feed = observer(function Feed({
   const renderItem = React.useCallback(
     ({item}: {item: any}) => {
       if (item === EMPTY_FEED_ITEM) {
-        if (renderEmptyState) {
-          return renderEmptyState()
-        }
-        return <View />
+        return renderEmptyState()
       } else if (item === ERROR_ITEM) {
         return (
           <ErrorMessage
@@ -181,7 +178,7 @@ export const Feed = observer(function Feed({
         scrollEventThrottle={scrollEventThrottle}
         indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
         onEndReached={onEndReached}
-        onEndReachedThreshold={0.6}
+        onEndReachedThreshold={2}
         removeClippedSubviews={true}
         contentOffset={{x: 0, y: headerOffset * -1}}
         extraData={extraData}
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index baf95af6c..5514bf98e 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -60,14 +60,14 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({
   if (!view || !view.hasLoaded) {
     return (
       <View style={pal.view}>
-        <LoadingPlaceholder width="100%" height={120} />
+        <LoadingPlaceholder width="100%" height={153} />
         <View
           style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
           <LoadingPlaceholder width={80} height={80} style={styles.br40} />
         </View>
         <View style={styles.content}>
           <View style={[styles.buttonsLine]}>
-            <LoadingPlaceholder width={100} height={31} style={styles.br50} />
+            <LoadingPlaceholder width={167} height={31} style={styles.br50} />
           </View>
           <View>
             <Text type="title-2xl" style={[pal.text, styles.title]}>
@@ -132,20 +132,19 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
   }, [store, view])
 
   const onPressToggleFollow = React.useCallback(() => {
-    track(
-      view.viewer.following
-        ? 'ProfileHeader:FollowButtonClicked'
-        : 'ProfileHeader:UnfollowButtonClicked',
-    )
     view?.toggleFollowing().then(
       () => {
         setShowSuggestedFollows(Boolean(view.viewer.following))
-
         Toast.show(
           `${
             view.viewer.following ? 'Following' : 'No longer following'
           } ${sanitizeDisplayName(view.displayName || view.handle)}`,
         )
+        track(
+          view.viewer.following
+            ? 'ProfileHeader:FollowButtonClicked'
+            : 'ProfileHeader:UnfollowButtonClicked',
+        )
       },
       err => store.log.error('Failed to toggle follow', err),
     )
diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
index 41e4022d5..cf759ddd1 100644
--- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
+++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {View, StyleSheet, ScrollView, Pressable} from 'react-native'
+import {View, StyleSheet, Pressable, ScrollView} from 'react-native'
 import Animated, {
   useSharedValue,
   withTiming,
@@ -26,6 +26,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
 import {Link} from 'view/com/util/Link'
 import {useAnalytics} from 'lib/analytics/analytics'
+import {isWeb} from 'platform/detection'
 
 const OUTER_PADDING = 10
 const INNER_PADDING = 14
@@ -100,7 +101,6 @@ export function ProfileHeaderSuggestedFollows({
             backgroundColor: pal.viewLight.backgroundColor,
             height: '100%',
             paddingTop: INNER_PADDING / 2,
-            paddingBottom: INNER_PADDING,
           }}>
           <View
             style={{
@@ -130,11 +130,15 @@ export function ProfileHeaderSuggestedFollows({
           </View>
 
           <ScrollView
-            horizontal
-            showsHorizontalScrollIndicator={false}
+            horizontal={true}
+            showsHorizontalScrollIndicator={isWeb}
+            persistentScrollbar={true}
+            scrollIndicatorInsets={{bottom: 0}}
+            scrollEnabled={true}
             contentContainerStyle={{
               alignItems: 'flex-start',
               paddingLeft: INNER_PADDING / 2,
+              paddingBottom: INNER_PADDING,
             }}>
             {isLoading ? (
               <>
@@ -223,9 +227,9 @@ const SuggestedFollow = observer(function SuggestedFollowImpl({
 
   const onPress = React.useCallback(async () => {
     try {
-      const {following} = await toggle()
+      const {following: isFollowing} = await toggle()
 
-      if (following) {
+      if (isFollowing) {
         track('ProfileHeader:SuggestedFollowFollowed')
       }
     } catch (e: any) {
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 6915d3e08..35524bcc6 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -1,5 +1,4 @@
-import React, {ComponentProps, useMemo} from 'react'
-import {observer} from 'mobx-react-lite'
+import React, {ComponentProps, memo, useMemo} from 'react'
 import {
   Linking,
   GestureResponderEvent,
@@ -50,7 +49,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> {
   anchorNoUnderline?: boolean
 }
 
-export const Link = observer(function Link({
+export const Link = memo(function Link({
   testID,
   style,
   href,
@@ -136,7 +135,7 @@ export const Link = observer(function Link({
   )
 })
 
-export const TextLink = observer(function TextLink({
+export const TextLink = memo(function TextLink({
   testID,
   type = 'md',
   style,
@@ -236,7 +235,7 @@ interface DesktopWebTextLinkProps extends TextProps {
   accessibilityHint?: string
   title?: string
 }
-export const DesktopWebTextLink = observer(function DesktopWebTextLink({
+export const DesktopWebTextLink = memo(function DesktopWebTextLink({
   testID,
   type = 'md',
   style,
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index d24e47499..fbc0b5e11 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -23,14 +23,18 @@ interface BaseUserAvatarProps {
   type?: Type
   size: number
   avatar?: string | null
-  moderation?: ModerationUI
 }
 
 interface UserAvatarProps extends BaseUserAvatarProps {
-  onSelectNewAvatar?: (img: RNImage | null) => void
+  moderation?: ModerationUI
+}
+
+interface EditableUserAvatarProps extends BaseUserAvatarProps {
+  onSelectNewAvatar: (img: RNImage | null) => void
 }
 
 interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
+  moderation?: ModerationUI
   did: string
   handle: string
 }
@@ -106,8 +110,65 @@ export function UserAvatar({
   size,
   avatar,
   moderation,
-  onSelectNewAvatar,
 }: UserAvatarProps) {
+  const pal = usePalette('default')
+
+  const aviStyle = useMemo(() => {
+    if (type === 'algo' || type === 'list') {
+      return {
+        width: size,
+        height: size,
+        borderRadius: size > 32 ? 8 : 3,
+      }
+    }
+    return {
+      width: size,
+      height: size,
+      borderRadius: Math.floor(size / 2),
+    }
+  }, [type, size])
+
+  const alert = useMemo(() => {
+    if (!moderation?.alert) {
+      return null
+    }
+    return (
+      <View style={[styles.alertIconContainer, pal.view]}>
+        <FontAwesomeIcon
+          icon="exclamation-circle"
+          style={styles.alertIcon}
+          size={Math.floor(size / 3)}
+        />
+      </View>
+    )
+  }, [moderation?.alert, size, pal])
+
+  return avatar &&
+    !((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
+    <View style={{width: size, height: size}}>
+      <HighPriorityImage
+        testID="userAvatarImage"
+        style={aviStyle}
+        contentFit="cover"
+        source={{uri: avatar}}
+        blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
+      />
+      {alert}
+    </View>
+  ) : (
+    <View style={{width: size, height: size}}>
+      <DefaultAvatar type={type} size={size} />
+      {alert}
+    </View>
+  )
+}
+
+export function EditableUserAvatar({
+  type = 'user',
+  size,
+  avatar,
+  onSelectNewAvatar,
+}: EditableUserAvatarProps) {
   const store = useStores()
   const pal = usePalette('default')
   const {requestCameraAccessIfNeeded} = useCameraPermission()
@@ -146,7 +207,7 @@ export function UserAvatar({
               return
             }
 
-            onSelectNewAvatar?.(
+            onSelectNewAvatar(
               await openCamera(store, {
                 width: 1000,
                 height: 1000,
@@ -186,7 +247,7 @@ export function UserAvatar({
               path: item.path,
             })
 
-            onSelectNewAvatar?.(croppedImage)
+            onSelectNewAvatar(croppedImage)
           },
         },
         !!avatar && {
@@ -203,7 +264,7 @@ export function UserAvatar({
             web: 'trash',
           },
           onPress: async () => {
-            onSelectNewAvatar?.(null)
+            onSelectNewAvatar(null)
           },
         },
       ].filter(Boolean) as DropdownItem[],
@@ -216,23 +277,7 @@ export function UserAvatar({
     ],
   )
 
-  const alert = useMemo(() => {
-    if (!moderation?.alert) {
-      return null
-    }
-    return (
-      <View style={[styles.alertIconContainer, pal.view]}>
-        <FontAwesomeIcon
-          icon="exclamation-circle"
-          style={styles.alertIcon}
-          size={Math.floor(size / 3)}
-        />
-      </View>
-    )
-  }, [moderation?.alert, size, pal])
-
-  // onSelectNewAvatar is only passed as prop on the EditProfile component
-  return onSelectNewAvatar ? (
+  return (
     <NativeDropdown
       testID="changeAvatarBtn"
       items={dropdownItems}
@@ -256,23 +301,6 @@ export function UserAvatar({
         />
       </View>
     </NativeDropdown>
-  ) : avatar &&
-    !((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
-    <View style={{width: size, height: size}}>
-      <HighPriorityImage
-        testID="userAvatarImage"
-        style={aviStyle}
-        contentFit="cover"
-        source={{uri: avatar}}
-        blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
-      />
-      {alert}
-    </View>
-  ) : (
-    <View style={{width: size, height: size}}>
-      <DefaultAvatar type={type} size={size} />
-      {alert}
-    </View>
   )
 }
 
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index 164028708..ec459b4eb 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -1,16 +1,17 @@
 import React from 'react'
 import {observer} from 'mobx-react-lite'
-import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {useNavigation} from '@react-navigation/native'
 import {CenteredView} from './Views'
 import {Text} from './text/Text'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {NavigationProp} from 'lib/routes/types'
+import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
+import Animated from 'react-native-reanimated'
 
 const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
 
@@ -149,30 +150,8 @@ const Container = observer(function ContainerImpl({
   hideOnScroll: boolean
   showBorder?: boolean
 }) {
-  const store = useStores()
   const pal = usePalette('default')
-  const interp = useAnimatedValue(0)
-
-  React.useEffect(() => {
-    if (store.shell.minimalShellMode) {
-      Animated.timing(interp, {
-        toValue: 1,
-        duration: 100,
-        useNativeDriver: true,
-        isInteraction: false,
-      }).start()
-    } else {
-      Animated.timing(interp, {
-        toValue: 0,
-        duration: 100,
-        useNativeDriver: true,
-        isInteraction: false,
-      }).start()
-    }
-  }, [interp, store.shell.minimalShellMode])
-  const transform = {
-    transform: [{translateY: Animated.multiply(interp, -100)}],
-  }
+  const {headerMinimalShellTransform} = useMinimalShellMode()
 
   if (!hideOnScroll) {
     return (
@@ -195,7 +174,7 @@ const Container = observer(function ContainerImpl({
         styles.headerFloating,
         pal.view,
         pal.border,
-        transform,
+        headerMinimalShellTransform,
         showBorder && styles.border,
       ]}>
       {children}
diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx
index 6c0e4c6cc..935d93033 100644
--- a/src/view/com/util/ViewSelector.tsx
+++ b/src/view/com/util/ViewSelector.tsx
@@ -144,8 +144,6 @@ export function Selector({
   items: string[]
   onSelect?: (index: number) => void
 }) {
-  const [height, setHeight] = useState(0)
-
   const pal = usePalette('default')
   const borderColor = useColorSchemeStyle(
     {borderColor: colors.black},
@@ -160,22 +158,13 @@ export function Selector({
     <View
       style={{
         width: '100%',
-        position: 'relative',
-        overflow: 'hidden',
-        height,
         backgroundColor: pal.colors.background,
       }}>
       <ScrollView
         testID="selector"
         horizontal
-        showsHorizontalScrollIndicator={false}
-        style={{position: 'absolute'}}>
-        <View
-          style={[pal.view, styles.outer]}
-          onLayout={e => {
-            const {height: layoutHeight} = e.nativeEvent.layout
-            setHeight(layoutHeight || 60)
-          }}>
+        showsHorizontalScrollIndicator={false}>
+        <View style={[pal.view, styles.outer]}>
           {items.map((item, i) => {
             const selected = i === selectedIndex
             return (
diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx
index 6c96eef2c..5b1d5d888 100644
--- a/src/view/com/util/fab/FABInner.tsx
+++ b/src/view/com/util/fab/FABInner.tsx
@@ -1,13 +1,13 @@
 import React, {ComponentProps} from 'react'
 import {observer} from 'mobx-react-lite'
-import {Animated, StyleSheet, TouchableWithoutFeedback} from 'react-native'
+import {StyleSheet, TouchableWithoutFeedback} from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
 import {gradients} from 'lib/styles'
-import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
-import {useStores} from 'state/index'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {clamp} from 'lib/numbers'
+import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
+import Animated from 'react-native-reanimated'
 
 export interface FABProps
   extends ComponentProps<typeof TouchableWithoutFeedback> {
@@ -21,28 +21,30 @@ export const FABInner = observer(function FABInnerImpl({
   ...props
 }: FABProps) {
   const insets = useSafeAreaInsets()
-  const {isTablet} = useWebMediaQueries()
-  const store = useStores()
-  const interp = useAnimatedValue(0)
-  React.useEffect(() => {
-    Animated.timing(interp, {
-      toValue: store.shell.minimalShellMode ? 0 : 1,
-      duration: 100,
-      useNativeDriver: true,
-      isInteraction: false,
-    }).start()
-  }, [interp, store.shell.minimalShellMode])
-  const transform = isTablet
-    ? undefined
-    : {
-        transform: [{translateY: Animated.multiply(interp, -44)}],
-      }
-  const size = isTablet ? styles.sizeLarge : styles.sizeRegular
-  const right = isTablet ? 50 : 24
-  const bottom = isTablet ? 50 : clamp(insets.bottom, 15, 60) + 15
+  const {isMobile, isTablet} = useWebMediaQueries()
+  const {fabMinimalShellTransform} = useMinimalShellMode()
+
+  const size = React.useMemo(() => {
+    return isTablet ? styles.sizeLarge : styles.sizeRegular
+  }, [isTablet])
+  const tabletSpacing = React.useMemo(() => {
+    return isTablet
+      ? {right: 50, bottom: 50}
+      : {
+          right: 24,
+          bottom: clamp(insets.bottom, 15, 60) + 15,
+        }
+  }, [insets.bottom, isTablet])
+
   return (
     <TouchableWithoutFeedback testID={testID} {...props}>
-      <Animated.View style={[styles.outer, size, {right, bottom}, transform]}>
+      <Animated.View
+        style={[
+          styles.outer,
+          size,
+          tabletSpacing,
+          isMobile && fabMinimalShellTransform,
+        ]}>
         <LinearGradient
           colors={[gradients.blueLight.start, gradients.blueLight.end]}
           start={{x: 0, y: 0}}
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 679f71c99..094b0c56c 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -23,19 +23,19 @@ export const GalleryItem: FC<GalleryItemProps> = ({
   onLongPress,
 }) => {
   const image = images[index]
-
   return (
-    <View>
+    <View style={styles.fullWidth}>
       <Pressable
         onPress={onPress ? () => onPress(index) : undefined}
         onPressIn={onPressIn ? () => onPressIn(index) : undefined}
         onLongPress={onLongPress ? () => onLongPress(index) : undefined}
+        style={styles.fullWidth}
         accessibilityRole="button"
         accessibilityLabel={image.alt || 'Image'}
         accessibilityHint="">
         <Image
           source={{uri: image.thumb}}
-          style={imageStyle}
+          style={[styles.image, imageStyle]}
           accessible={true}
           accessibilityLabel={image.alt}
           accessibilityHint=""
@@ -54,14 +54,21 @@ export const GalleryItem: FC<GalleryItemProps> = ({
 }
 
 const styles = StyleSheet.create({
+  fullWidth: {
+    flex: 1,
+  },
+  image: {
+    flex: 1,
+    borderRadius: 4,
+  },
   altContainer: {
     backgroundColor: 'rgba(0, 0, 0, 0.75)',
     borderRadius: 6,
     paddingHorizontal: 6,
     paddingVertical: 3,
     position: 'absolute',
-    left: 6,
-    bottom: 6,
+    left: 8,
+    bottom: 8,
   },
   alt: {
     color: 'white',
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index 4c0901304..4aa6f28de 100644
--- a/src/view/com/util/images/ImageLayoutGrid.tsx
+++ b/src/view/com/util/images/ImageLayoutGrid.tsx
@@ -1,13 +1,5 @@
-import React, {useMemo, useState} from 'react'
-import {
-  LayoutChangeEvent,
-  StyleProp,
-  StyleSheet,
-  View,
-  ViewStyle,
-} from 'react-native'
-import {ImageStyle} from 'expo-image'
-import {Dimensions} from 'lib/media/types'
+import React from 'react'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {AppBskyEmbedImages} from '@atproto/api'
 import {GalleryItem} from './Gallery'
 
@@ -20,21 +12,11 @@ interface ImageLayoutGridProps {
 }
 
 export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
-  const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
-
-  const onLayout = (evt: LayoutChangeEvent) => {
-    const {width, height} = evt.nativeEvent.layout
-    setContainerInfo({
-      width,
-      height,
-    })
-  }
-
   return (
-    <View style={style} onLayout={onLayout}>
-      {containerInfo ? (
-        <ImageLayoutGridInner {...props} containerInfo={containerInfo} />
-      ) : undefined}
+    <View style={style}>
+      <View style={styles.container}>
+        <ImageLayoutGridInner {...props} />
+      </View>
     </View>
   )
 }
@@ -44,70 +26,80 @@ interface ImageLayoutGridInnerProps {
   onPress?: (index: number) => void
   onLongPress?: (index: number) => void
   onPressIn?: (index: number) => void
-  containerInfo: Dimensions
 }
 
-function ImageLayoutGridInner({
-  containerInfo,
-  ...props
-}: ImageLayoutGridInnerProps) {
+function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
   const count = props.images.length
-  const size1 = useMemo<ImageStyle>(() => {
-    if (count === 3) {
-      const size = (containerInfo.width - 10) / 3
-      return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
-    } else {
-      const size = (containerInfo.width - 5) / 2
-      return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
-    }
-  }, [count, containerInfo])
-  const size2 = React.useMemo<ImageStyle>(() => {
-    if (count === 3) {
-      const size = ((containerInfo.width - 10) / 3) * 2 + 5
-      return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
-    } else {
-      const size = (containerInfo.width - 5) / 2
-      return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
-    }
-  }, [count, containerInfo])
 
   switch (count) {
     case 2:
       return (
         <View style={styles.flexRow}>
-          <GalleryItem index={0} {...props} imageStyle={size1} />
-          <GalleryItem index={1} {...props} imageStyle={size1} />
+          <View style={styles.smallItem}>
+            <GalleryItem {...props} index={0} imageStyle={styles.image} />
+          </View>
+          <View style={styles.smallItem}>
+            <GalleryItem {...props} index={1} imageStyle={styles.image} />
+          </View>
         </View>
       )
+
     case 3:
       return (
         <View style={styles.flexRow}>
-          <GalleryItem index={0} {...props} imageStyle={size2} />
-          <View style={styles.flexColumn}>
-            <GalleryItem index={1} {...props} imageStyle={size1} />
-            <GalleryItem index={2} {...props} imageStyle={size1} />
+          <View style={{flex: 2, aspectRatio: 1}}>
+            <GalleryItem {...props} index={0} imageStyle={styles.image} />
+          </View>
+          <View style={{flex: 1}}>
+            <View style={styles.smallItem}>
+              <GalleryItem {...props} index={1} imageStyle={styles.image} />
+            </View>
+            <View style={styles.smallItem}>
+              <GalleryItem {...props} index={2} imageStyle={styles.image} />
+            </View>
           </View>
         </View>
       )
+
     case 4:
       return (
-        <View style={styles.flexRow}>
-          <View style={styles.flexColumn}>
-            <GalleryItem index={0} {...props} imageStyle={size1} />
-            <GalleryItem index={2} {...props} imageStyle={size1} />
+        <>
+          <View style={styles.flexRow}>
+            <View style={styles.smallItem}>
+              <GalleryItem {...props} index={0} imageStyle={styles.image} />
+            </View>
+            <View style={styles.smallItem}>
+              <GalleryItem {...props} index={2} imageStyle={styles.image} />
+            </View>
           </View>
-          <View style={styles.flexColumn}>
-            <GalleryItem index={1} {...props} imageStyle={size1} />
-            <GalleryItem index={3} {...props} imageStyle={size1} />
+          <View style={styles.flexRow}>
+            <View style={styles.smallItem}>
+              <GalleryItem {...props} index={1} imageStyle={styles.image} />
+            </View>
+            <View style={styles.smallItem}>
+              <GalleryItem {...props} index={3} imageStyle={styles.image} />
+            </View>
           </View>
-        </View>
+        </>
       )
+
     default:
       return null
   }
 }
 
+// This is used to compute margins (rather than flexbox gap) due to Yoga bugs:
+// https://github.com/facebook/yoga/issues/1418
+const IMAGE_GAP = 5
+
 const styles = StyleSheet.create({
-  flexRow: {flexDirection: 'row', gap: 5},
-  flexColumn: {flexDirection: 'column', gap: 5},
+  container: {
+    marginHorizontal: -IMAGE_GAP / 2,
+    marginVertical: -IMAGE_GAP / 2,
+  },
+  flexRow: {flexDirection: 'row'},
+  smallItem: {flex: 1, aspectRatio: 1},
+  image: {
+    margin: IMAGE_GAP / 2,
+  },
 })
diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx
index f5d12ce2c..b16a42396 100644
--- a/src/view/com/util/load-latest/LoadLatestBtn.tsx
+++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx
@@ -2,14 +2,14 @@ import React from 'react'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {colors} from 'lib/styles'
 import {HITSLOP_20} from 'lib/constants'
-import {isWeb} from 'platform/detection'
-import {clamp} from 'lib/numbers'
+import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
+import Animated from 'react-native-reanimated'
+const AnimatedTouchableOpacity =
+  Animated.createAnimatedComponent(TouchableOpacity)
 
 export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
   onPress,
@@ -19,26 +19,20 @@ export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
   onPress: () => void
   label: string
   showIndicator: boolean
-  minimalShellMode?: boolean // NOTE not used on mobile -prf
 }) {
-  const store = useStores()
   const pal = usePalette('default')
-  const {isDesktop, isTablet} = useWebMediaQueries()
-  const safeAreaInsets = useSafeAreaInsets()
-  const minMode = store.shell.minimalShellMode
-  const bottom = isTablet
-    ? 50
-    : (minMode || isDesktop ? 16 : 60) +
-      (isWeb ? 20 : clamp(safeAreaInsets.bottom, 15, 60))
+  const {isDesktop, isTablet, isMobile} = useWebMediaQueries()
+  const {fabMinimalShellTransform} = useMinimalShellMode()
+
   return (
-    <TouchableOpacity
+    <AnimatedTouchableOpacity
       style={[
         styles.loadLatest,
         isDesktop && styles.loadLatestDesktop,
         isTablet && styles.loadLatestTablet,
         pal.borderDark,
         pal.view,
-        {bottom},
+        isMobile && fabMinimalShellTransform,
       ]}
       onPress={onPress}
       hitSlop={HITSLOP_20}
@@ -47,7 +41,7 @@ export const LoadLatestBtn = observer(function LoadLatestBtnImpl({
       accessibilityHint="">
       <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} />
       {showIndicator && <View style={[styles.indicator, pal.borderDark]} />}
-    </TouchableOpacity>
+    </AnimatedTouchableOpacity>
   )
 })
 
@@ -66,15 +60,11 @@ const styles = StyleSheet.create({
   },
   loadLatestTablet: {
     // @ts-ignore web only
-    left: '50vw',
-    // @ts-ignore web only -prf
-    transform: 'translateX(-282px)',
+    left: 'calc(50vw - 282px)',
   },
   loadLatestDesktop: {
     // @ts-ignore web only
-    left: '50vw',
-    // @ts-ignore web only -prf
-    transform: 'translateX(-382px)',
+    left: 'calc(50vw - 382px)',
   },
   indicator: {
     position: 'absolute',
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 8560ad445..ad47e9f9b 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,33 +1,22 @@
 import React from 'react'
-import {FlatList, View, useWindowDimensions} from 'react-native'
-import {useFocusEffect, useIsFocused} from '@react-navigation/native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
+import {useWindowDimensions} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
 import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api'
 import {observer} from 'mobx-react-lite'
-import useAppState from 'react-native-appstate-hook'
 import isEqual from 'lodash.isequal'
 import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
 import {PostsFeedModel} from 'state/models/feeds/posts'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {TextLink} from 'view/com/util/Link'
-import {Feed} from '../com/posts/Feed'
 import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
 import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
 import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
-import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn'
 import {FeedsTabBar} from '../com/pager/FeedsTabBar'
 import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
-import {FAB} from '../com/util/fab/FAB'
 import {useStores} from 'state/index'
-import {usePalette} from 'lib/hooks/usePalette'
-import {s, colors} from 'lib/styles'
-import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
-import {useAnalytics} from 'lib/analytics/analytics'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {ComposeIcon2} from 'lib/icons'
+import {FeedPage} from 'view/com/feeds/FeedPage'
 
-const POLL_FREQ = 30e3 // 30sec
+export const POLL_FREQ = 30e3 // 30sec
 
 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
 export const HomeScreen = withAuthRequired(
@@ -98,7 +87,9 @@ export const HomeScreen = withAuthRequired(
       (props: RenderTabBarFnProps) => {
         return (
           <FeedsTabBar
-            {...props}
+            key="FEEDS_TAB_BAR"
+            selectedPage={props.selectedPage}
+            onSelect={props.onSelect}
             testID="homeScreenFeedTabs"
             onPressSelected={onPressSelected}
           />
@@ -111,10 +102,6 @@ export const HomeScreen = withAuthRequired(
       return <FollowingEmptyState />
     }, [])
 
-    const renderFollowingEndOfFeed = React.useCallback(() => {
-      return <FollowingEndOfFeed />
-    }, [])
-
     const renderCustomFeedEmptyState = React.useCallback(() => {
       return <CustomFeedEmptyState />
     }, [])
@@ -132,7 +119,7 @@ export const HomeScreen = withAuthRequired(
           isPageFocused={selectedPage === 0}
           feed={store.me.mainFeed}
           renderEmptyState={renderFollowingEmptyState}
-          renderEndOfFeed={renderFollowingEndOfFeed}
+          renderEndOfFeed={FollowingEndOfFeed}
         />
         {customFeeds.map((f, index) => {
           return (
@@ -150,196 +137,7 @@ export const HomeScreen = withAuthRequired(
   }),
 )
 
-const FeedPage = observer(function FeedPageImpl({
-  testID,
-  isPageFocused,
-  feed,
-  renderEmptyState,
-  renderEndOfFeed,
-}: {
-  testID?: string
-  feed: PostsFeedModel
-  isPageFocused: boolean
-  renderEmptyState?: () => JSX.Element
-  renderEndOfFeed?: () => JSX.Element
-}) {
-  const store = useStores()
-  const pal = usePalette('default')
-  const {isDesktop} = useWebMediaQueries()
-  const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store)
-  const {screen, track} = useAnalytics()
-  const headerOffset = useHeaderOffset()
-  const scrollElRef = React.useRef<FlatList>(null)
-  const {appState} = useAppState({
-    onForeground: () => doPoll(true),
-  })
-  const isScreenFocused = useIsFocused()
-  const hasNew = feed.hasNewLatest && !feed.isRefreshing
-
-  React.useEffect(() => {
-    // called on first load
-    if (!feed.hasLoaded && isPageFocused) {
-      feed.setup()
-    }
-  }, [isPageFocused, feed])
-
-  const doPoll = React.useCallback(
-    (knownActive = false) => {
-      if (
-        (!knownActive && appState !== 'active') ||
-        !isScreenFocused ||
-        !isPageFocused
-      ) {
-        return
-      }
-      if (feed.isLoading) {
-        return
-      }
-      store.log.debug('HomeScreen: Polling for new posts')
-      feed.checkForLatest()
-    },
-    [appState, isScreenFocused, isPageFocused, store, feed],
-  )
-
-  const scrollToTop = React.useCallback(() => {
-    scrollElRef.current?.scrollToOffset({offset: -headerOffset})
-    resetMainScroll()
-  }, [headerOffset, resetMainScroll])
-
-  const onSoftReset = React.useCallback(() => {
-    if (isPageFocused) {
-      scrollToTop()
-      feed.refresh()
-    }
-  }, [isPageFocused, scrollToTop, feed])
-
-  // fires when page within screen is activated/deactivated
-  // - check for latest
-  React.useEffect(() => {
-    if (!isPageFocused || !isScreenFocused) {
-      return
-    }
-
-    const softResetSub = store.onScreenSoftReset(onSoftReset)
-    const feedCleanup = feed.registerListeners()
-    const pollInterval = setInterval(doPoll, POLL_FREQ)
-
-    screen('Feed')
-    store.log.debug('HomeScreen: Updating feed')
-    feed.checkForLatest()
-
-    return () => {
-      clearInterval(pollInterval)
-      softResetSub.remove()
-      feedCleanup()
-    }
-  }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused])
-
-  const onPressCompose = React.useCallback(() => {
-    track('HomeScreen:PressCompose')
-    store.shell.openComposer({})
-  }, [store, track])
-
-  const onPressTryAgain = React.useCallback(() => {
-    feed.refresh()
-  }, [feed])
-
-  const onPressLoadLatest = React.useCallback(() => {
-    scrollToTop()
-    feed.refresh()
-  }, [feed, scrollToTop])
-
-  const ListHeaderComponent = React.useCallback(() => {
-    if (isDesktop) {
-      return (
-        <View
-          style={[
-            pal.view,
-            {
-              flexDirection: 'row',
-              alignItems: 'center',
-              justifyContent: 'space-between',
-              paddingHorizontal: 18,
-              paddingVertical: 12,
-            },
-          ]}>
-          <TextLink
-            type="title-lg"
-            href="/"
-            style={[pal.text, {fontWeight: 'bold'}]}
-            text={
-              <>
-                {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '}
-                {hasNew && (
-                  <View
-                    style={{
-                      top: -8,
-                      backgroundColor: colors.blue3,
-                      width: 8,
-                      height: 8,
-                      borderRadius: 4,
-                    }}
-                  />
-                )}
-              </>
-            }
-            onPress={() => store.emitScreenSoftReset()}
-          />
-          <TextLink
-            type="title-lg"
-            href="/settings/home-feed"
-            style={{fontWeight: 'bold'}}
-            accessibilityLabel="Feed Preferences"
-            accessibilityHint=""
-            text={
-              <FontAwesomeIcon
-                icon="sliders"
-                style={pal.textLight as FontAwesomeIconStyle}
-              />
-            }
-          />
-        </View>
-      )
-    }
-    return <></>
-  }, [isDesktop, pal, store, hasNew])
-
-  return (
-    <View testID={testID} style={s.h100pct}>
-      <Feed
-        testID={testID ? `${testID}-feed` : undefined}
-        key="default"
-        feed={feed}
-        scrollElRef={scrollElRef}
-        onPressTryAgain={onPressTryAgain}
-        onScroll={onMainScroll}
-        scrollEventThrottle={100}
-        renderEmptyState={renderEmptyState}
-        renderEndOfFeed={renderEndOfFeed}
-        ListHeaderComponent={ListHeaderComponent}
-        headerOffset={headerOffset}
-      />
-      {(isScrolledDown || hasNew) && (
-        <LoadLatestBtn
-          onPress={onPressLoadLatest}
-          label="Load new posts"
-          showIndicator={hasNew}
-          minimalShellMode={store.shell.minimalShellMode}
-        />
-      )}
-      <FAB
-        testID="composeFAB"
-        onPress={onPressCompose}
-        icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
-        accessibilityRole="button"
-        accessibilityLabel="New post"
-        accessibilityHint=""
-      />
-    </View>
-  )
-})
-
-function useHeaderOffset() {
+export function useHeaderOffset() {
   const {isDesktop, isTablet} = useWebMediaQueries()
   const {fontScale} = useWindowDimensions()
   if (isDesktop) {
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index 977401350..b00bfb765 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -156,7 +156,6 @@ export const NotificationsScreen = withAuthRequired(
             onPress={onPressLoadLatest}
             label="Load new notifications"
             showIndicator={hasNew}
-            minimalShellMode={true}
           />
         )}
       </View>
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index f75222c1f..2112ec7d1 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -322,45 +322,37 @@ export const SettingsScreen = withAuthRequired(
 
           <View style={styles.spacer20} />
 
-          {store.me.invitesAvailable !== null && (
-            <>
-              <Text type="xl-bold" style={[pal.text, styles.heading]}>
-                Invite a Friend
-              </Text>
-              <TouchableOpacity
-                testID="inviteFriendBtn"
-                style={[
-                  styles.linkCard,
-                  pal.view,
-                  isSwitching && styles.dimmed,
-                ]}
-                onPress={isSwitching ? undefined : onPressInviteCodes}
-                accessibilityRole="button"
-                accessibilityLabel="Invite"
-                accessibilityHint="Opens invite code list">
-                <View
-                  style={[
-                    styles.iconContainer,
-                    store.me.invitesAvailable > 0 ? primaryBg : pal.btn,
-                  ]}>
-                  <FontAwesomeIcon
-                    icon="ticket"
-                    style={
-                      (store.me.invitesAvailable > 0
-                        ? primaryText
-                        : pal.text) as FontAwesomeIconStyle
-                    }
-                  />
-                </View>
-                <Text
-                  type="lg"
-                  style={store.me.invitesAvailable > 0 ? pal.link : pal.text}>
-                  {formatCount(store.me.invitesAvailable)} invite{' '}
-                  {pluralize(store.me.invitesAvailable, 'code')} available
-                </Text>
-              </TouchableOpacity>
-            </>
-          )}
+          <Text type="xl-bold" style={[pal.text, styles.heading]}>
+            Invite a Friend
+          </Text>
+          <TouchableOpacity
+            testID="inviteFriendBtn"
+            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
+            onPress={isSwitching ? undefined : onPressInviteCodes}
+            accessibilityRole="button"
+            accessibilityLabel="Invite"
+            accessibilityHint="Opens invite code list">
+            <View
+              style={[
+                styles.iconContainer,
+                store.me.invitesAvailable > 0 ? primaryBg : pal.btn,
+              ]}>
+              <FontAwesomeIcon
+                icon="ticket"
+                style={
+                  (store.me.invitesAvailable > 0
+                    ? primaryText
+                    : pal.text) as FontAwesomeIconStyle
+                }
+              />
+            </View>
+            <Text
+              type="lg"
+              style={store.me.invitesAvailable > 0 ? pal.link : pal.text}>
+              {formatCount(store.me.invitesAvailable)} invite{' '}
+              {pluralize(store.me.invitesAvailable, 'code')} available
+            </Text>
+          </TouchableOpacity>
 
           <View style={styles.spacer20} />
 
diff --git a/src/view/screens/Support.tsx b/src/view/screens/Support.tsx
index de1b38b84..dc00d473d 100644
--- a/src/view/screens/Support.tsx
+++ b/src/view/screens/Support.tsx
@@ -9,6 +9,7 @@ import {TextLink} from 'view/com/util/Link'
 import {CenteredView} from 'view/com/util/Views'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
+import {HELP_DESK_URL} from 'lib/constants'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Support'>
 export const SupportScreen = (_props: Props) => {
@@ -29,14 +30,13 @@ export const SupportScreen = (_props: Props) => {
           Support
         </Text>
         <Text style={[pal.text, s.p20]}>
-          If you need help, email us at{' '}
+          The support form has been moved. If you need help, please
           <TextLink
-            href="mailto:support@bsky.app"
-            text="support@bsky.app"
+            href={HELP_DESK_URL}
+            text=" click here"
             style={pal.link}
           />{' '}
-          with a description of your issue and information about how we can help
-          you.
+          or visit {HELP_DESK_URL} to get in touch with us.
         </Text>
       </CenteredView>
     </View>
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 48341170c..51a846c4a 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -426,34 +426,32 @@ const InviteCodes = observer(function InviteCodesImpl({
     store.shell.openModal({name: 'invite-codes'})
   }, [store, track])
   return (
-    store.me.invitesAvailable !== null && (
-      <TouchableOpacity
-        testID="menuItemInviteCodes"
-        style={[styles.inviteCodes, style]}
-        onPress={onPress}
-        accessibilityRole="button"
-        accessibilityLabel={
-          invitesAvailable === 1
-            ? 'Invite codes: 1 available'
-            : `Invite codes: ${invitesAvailable} available`
-        }
-        accessibilityHint="Opens list of invite codes">
-        <FontAwesomeIcon
-          icon="ticket"
-          style={[
-            styles.inviteCodesIcon,
-            store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
-          ]}
-          size={18}
-        />
-        <Text
-          type="lg-medium"
-          style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
-          {formatCount(store.me.invitesAvailable)} invite{' '}
-          {pluralize(store.me.invitesAvailable, 'code')}
-        </Text>
-      </TouchableOpacity>
-    )
+    <TouchableOpacity
+      testID="menuItemInviteCodes"
+      style={[styles.inviteCodes, style]}
+      onPress={onPress}
+      accessibilityRole="button"
+      accessibilityLabel={
+        invitesAvailable === 1
+          ? 'Invite codes: 1 available'
+          : `Invite codes: ${invitesAvailable} available`
+      }
+      accessibilityHint="Opens list of invite codes">
+      <FontAwesomeIcon
+        icon="ticket"
+        style={[
+          styles.inviteCodesIcon,
+          store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
+        ]}
+        size={18}
+      />
+      <Text
+        type="lg-medium"
+        style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
+        {formatCount(store.me.invitesAvailable)} invite{' '}
+        {pluralize(store.me.invitesAvailable, 'code')}
+      </Text>
+    </TouchableOpacity>
   )
 })
 
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index 4758c5e01..cfd4d46d0 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -1,10 +1,6 @@
 import React, {ComponentProps} from 'react'
-import {
-  Animated,
-  GestureResponderEvent,
-  TouchableOpacity,
-  View,
-} from 'react-native'
+import {GestureResponderEvent, TouchableOpacity, View} from 'react-native'
+import Animated from 'react-native-reanimated'
 import {StackActions} from '@react-navigation/native'
 import {BottomTabBarProps} from '@react-navigation/bottom-tabs'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
@@ -87,6 +83,7 @@ export const BottomBar = observer(function BottomBarImpl({
         pal.border,
         {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
         footerMinimalShellTransform,
+        store.shell.minimalShellMode && styles.disabled,
       ]}>
       <Btn
         testID="bottomBarHomeBtn"
diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx
index ae9381440..c175ed848 100644
--- a/src/view/shell/bottom-bar/BottomBarStyles.tsx
+++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx
@@ -65,4 +65,7 @@ export const styles = StyleSheet.create({
     borderWidth: 1,
     borderRadius: 100,
   },
+  disabled: {
+    pointerEvents: 'none',
+  },
 })
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index e20214235..ebcc527a1 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -2,8 +2,8 @@ import React from 'react'
 import {observer} from 'mobx-react-lite'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
-import {Animated} from 'react-native'
 import {useNavigationState} from '@react-navigation/native'
+import Animated from 'react-native-reanimated'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {getCurrentRoute, isTab} from 'lib/routes/helpers'
 import {styles} from './BottomBarStyles'
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index f0e986bf4..84d7d7854 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -7,7 +7,6 @@ import {DesktopSearch} from './Search'
 import {DesktopFeeds} from './Feeds'
 import {Text} from 'view/com/util/text/Text'
 import {TextLink} from 'view/com/util/Link'
-import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
 import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
 import {s} from 'lib/styles'
 import {useStores} from 'state/index'
@@ -90,41 +89,32 @@ const InviteCodes = observer(function InviteCodesImpl() {
   const onPress = React.useCallback(() => {
     store.shell.openModal({name: 'invite-codes'})
   }, [store])
-
   return (
-    <View style={[styles.separator, pal.border]}>
-      {store.me.invitesAvailable === null ? (
-        <View style={[s.p10]}>
-          <LoadingPlaceholder width={186} height={32} style={[styles.br40]} />
-        </View>
-      ) : (
-        <TouchableOpacity
-          style={[styles.inviteCodes]}
-          onPress={onPress}
-          accessibilityRole="button"
-          accessibilityLabel={
-            invitesAvailable === 1
-              ? 'Invite codes: 1 available'
-              : `Invite codes: ${invitesAvailable} available`
-          }
-          accessibilityHint="Opens list of invite codes">
-          <FontAwesomeIcon
-            icon="ticket"
-            style={[
-              styles.inviteCodesIcon,
-              store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
-            ]}
-            size={16}
-          />
-          <Text
-            type="md-medium"
-            style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
-            {formatCount(store.me.invitesAvailable)} invite{' '}
-            {pluralize(store.me.invitesAvailable, 'code')} available
-          </Text>
-        </TouchableOpacity>
-      )}
-    </View>
+    <TouchableOpacity
+      style={[styles.inviteCodes, pal.border]}
+      onPress={onPress}
+      accessibilityRole="button"
+      accessibilityLabel={
+        invitesAvailable === 1
+          ? 'Invite codes: 1 available'
+          : `Invite codes: ${invitesAvailable} available`
+      }
+      accessibilityHint="Opens list of invite codes">
+      <FontAwesomeIcon
+        icon="ticket"
+        style={[
+          styles.inviteCodesIcon,
+          store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
+        ]}
+        size={16}
+      />
+      <Text
+        type="md-medium"
+        style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
+        {formatCount(store.me.invitesAvailable)} invite{' '}
+        {pluralize(store.me.invitesAvailable, 'code')} available
+      </Text>
+    </TouchableOpacity>
   )
 })
 
@@ -141,20 +131,16 @@ const styles = StyleSheet.create({
 
   message: {
     paddingVertical: 18,
-    paddingHorizontal: 12,
+    paddingHorizontal: 10,
   },
   messageLine: {
     marginBottom: 10,
   },
 
-  separator: {
-    borderTopWidth: 1,
-  },
-  br40: {borderRadius: 40},
-
   inviteCodes: {
-    paddingHorizontal: 12,
-    paddingVertical: 16,
+    borderTopWidth: 1,
+    paddingHorizontal: 16,
+    paddingVertical: 12,
     flexDirection: 'row',
     alignItems: 'center',
   },
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index 53a58c39d..caecea4a8 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -22,6 +22,13 @@ export const DesktopSearch = observer(function DesktopSearch() {
   )
   const navigation = useNavigation<NavigationProp>()
 
+  // initial setup
+  React.useEffect(() => {
+    if (store.me.did) {
+      autocompleteView.setup()
+    }
+  }, [autocompleteView, store.me.did])
+
   const onChangeQuery = React.useCallback(
     (text: string) => {
       setQuery(text)
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 3119715e9..b564f99f8 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -21,7 +21,10 @@ import {usePalette} from 'lib/hooks/usePalette'
 import * as backHandler from 'lib/routes/back-handler'
 import {RoutesContainer, TabsNavigator} from '../../Navigation'
 import {isStateAtTabRoot} from 'lib/routes/helpers'
-import {SafeAreaProvider} from 'react-native-safe-area-context'
+import {
+  SafeAreaProvider,
+  initialWindowMetrics,
+} from 'react-native-safe-area-context'
 import {useOTAUpdate} from 'lib/hooks/useOTAUpdate'
 
 const ShellInner = observer(function ShellInnerImpl() {
@@ -87,7 +90,7 @@ export const Shell: React.FC = observer(function ShellImpl() {
   const pal = usePalette('default')
   const theme = useTheme()
   return (
-    <SafeAreaProvider style={pal.view}>
+    <SafeAreaProvider initialMetrics={initialWindowMetrics} style={pal.view}>
       <View testID="mobileShellView" style={[styles.outerContainer, pal.view]}>
         <StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} />
         <RoutesContainer>
diff --git a/yarn.lock b/yarn.lock
index fffbff57e..906e84650 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -47,18 +47,19 @@
     tlds "^1.234.0"
     typed-emitter "^2.1.0"
 
-"@atproto/api@^0.6.20":
-  version "0.6.20"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.20.tgz#3a7eda60d73a5d5b6938e2dd016c24a7ba180c83"
-  integrity sha512-+peoKgkaxbglXQg9qEZcZIvyWm39yj0+syV3TBDrz5cWK4OIsdOyYBg2iISy+jvB5RzEUMe2WvOojP6Nq34mOg==
-  dependencies:
-    "@atproto/common-web" "^0.2.1"
-    "@atproto/lexicon" "^0.2.2"
-    "@atproto/syntax" "^0.1.2"
-    "@atproto/xrpc" "^0.3.2"
+"@atproto/api@^0.6.21":
+  version "0.6.21"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.21.tgz#6e5b00facf46f2556d9766290341aae7e6ef75c8"
+  integrity sha512-ZWVEnLhZ8nonkCVzeFgdUFZhTOUtPxvicZFuttvb2G2Q5u43RmJ5qXXZvox/S9XQEw7TubG6Jza1mesH7CjfVQ==
+  dependencies:
+    "@atproto/common-web" "^0.2.2"
+    "@atproto/lexicon" "^0.2.3"
+    "@atproto/syntax" "^0.1.3"
+    "@atproto/xrpc" "^0.3.3"
     multiformats "^9.9.0"
     tlds "^1.234.0"
     typed-emitter "^2.1.0"
+    zod "^3.21.4"
 
 "@atproto/bsky@^0.0.5":
   version "0.0.5"
@@ -105,10 +106,10 @@
     uint8arrays "3.0.0"
     zod "^3.21.4"
 
-"@atproto/common-web@^0.2.1":
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.2.1.tgz#97412cb241321fc6c56a2b8c0b2416b3240caf50"
-  integrity sha512-5AoDKkKz7JhXSiicjhPihA/MJMlSuTQ9Aed9fflPuoTuT6C3aXbxaUZEcqqipSwlCfGpOzPmJmWJjMWWsYr2ew==
+"@atproto/common-web@^0.2.2":
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.2.2.tgz#decc12584c84f3c34d077d1afe7442bfc21bcf6c"
+  integrity sha512-XWZHj82kWGdhm0y6e/DxLA5qK0LPHTozfPCH2ws1B/Qh9Hh5DD/gakvlIRT1FouwPM+hWcs8YHVJ8bjnehrhHA==
   dependencies:
     graphemer "^1.4.0"
     multiformats "^9.9.0"
@@ -219,13 +220,13 @@
     multiformats "^9.9.0"
     zod "^3.21.4"
 
-"@atproto/lexicon@^0.2.2":
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.2.2.tgz#938a39482ff41c6a908f4ad43274adba595f3643"
-  integrity sha512-CvmjaSDavHMOJTuNYE8VjYhL7TVxBYV8QSWh2jHCpzfmj02DvVD9UBIfnoVv67POJkEtWXddjoV9beaIbaq/Xg==
+"@atproto/lexicon@^0.2.3":
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.2.3.tgz#3f8ba24187d5628ec06b1bdbec90747f7cdc0948"
+  integrity sha512-1xUs0KNw4CopWI5HSlLYZ8UHW5nb6V7sldO5OPONiEVKjETrqqjfopezloYAIBNrekUNXwd1pbp05afkAxW5og==
   dependencies:
-    "@atproto/common-web" "^0.2.1"
-    "@atproto/syntax" "^0.1.2"
+    "@atproto/common-web" "^0.2.2"
+    "@atproto/syntax" "^0.1.3"
     iso-datestring-validator "^2.2.2"
     multiformats "^9.9.0"
     zod "^3.21.4"
@@ -297,12 +298,12 @@
   dependencies:
     "@atproto/common-web" "^0.2.0"
 
-"@atproto/syntax@^0.1.2":
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.1.2.tgz#417366d36b53ecf29d9d1f6e35179b1f3feef95b"
-  integrity sha512-n6VSuccMGouwftCvZBq9WNwI0qYCMOH/lTHSV+/dT232lX7pIrqisOlErUSBoOJ49B1Wxy1DjeeBS26ap9SsGQ==
+"@atproto/syntax@^0.1.3":
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.1.3.tgz#5cafd5d82eee939fde06a2eacd11b264fb2f3b13"
+  integrity sha512-Xbw+Rx15puW8wZ/ro40nAQVc7ymPqcGOinVt8Jxi+lcY/1iKpID9a86E6ZOzvw0ncFKONwILYk1+xGeUT6OUNA==
   dependencies:
-    "@atproto/common-web" "^0.2.1"
+    "@atproto/common-web" "^0.2.2"
 
 "@atproto/xrpc-server@^0.3.1":
   version "0.3.1"
@@ -329,12 +330,12 @@
     "@atproto/lexicon" "^0.2.1"
     zod "^3.21.4"
 
-"@atproto/xrpc@^0.3.2":
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.3.2.tgz#432a364be4b3bf8660a088a07dadecac10209763"
-  integrity sha512-D9jGjcFnEMHuGQ56v6+78uX3RiytKLrA5ITLq6shy0Qj6Zvt5MqV+/cTFuNPKrNCrnWOtHFeRQwMqyGhNS9qZQ==
+"@atproto/xrpc@^0.3.3":
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.3.3.tgz#05f1c431ccd366e950637b93acca85faa249f52b"
+  integrity sha512-o0VUrUGu5Y/1F+ujZKIJYpuHdfXaIDacxuiq2IjwR2rbHXlefh+9FJy5XNkq4do+jMj7U+gSiPrgqaqLYbc9ng==
   dependencies:
-    "@atproto/lexicon" "^0.2.2"
+    "@atproto/lexicon" "^0.2.3"
     zod "^3.21.4"
 
 "@babel/code-frame@7.10.4", "@babel/code-frame@~7.10.4":
@@ -3744,6 +3745,16 @@
     "@sentry/utils" "7.52.1"
     tslib "^1.9.3"
 
+"@sentry-internal/tracing@7.69.0":
+  version "7.69.0"
+  resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.69.0.tgz#8d8eb740b72967b6ba3fdc0a5173aa55331b7d35"
+  integrity sha512-4BgeWZUj9MO6IgfO93C9ocP3+AdngqujF/+zB2rFdUe+y9S6koDyUC7jr9Knds/0Ta72N/0D6PwhgSCpHK8s0Q==
+  dependencies:
+    "@sentry/core" "7.69.0"
+    "@sentry/types" "7.69.0"
+    "@sentry/utils" "7.69.0"
+    tslib "^2.4.1 || ^1.9.3"
+
 "@sentry/browser@7.52.0":
   version "7.52.0"
   resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.52.0.tgz#55d266c89ed668389ff687e5cc885c27016ea85c"
@@ -3768,6 +3779,18 @@
     "@sentry/utils" "7.52.1"
     tslib "^1.9.3"
 
+"@sentry/browser@7.69.0":
+  version "7.69.0"
+  resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.69.0.tgz#65427c90fb71c1775e2c1e38431efb7f4aec1e34"
+  integrity sha512-5ls+zu2PrMhHCIIhclKQsWX5u6WH0Ez5/GgrCMZTtZ1d70ukGSRUvpZG9qGf5Cw1ezS1LY+1HCc3whf8x8lyPw==
+  dependencies:
+    "@sentry-internal/tracing" "7.69.0"
+    "@sentry/core" "7.69.0"
+    "@sentry/replay" "7.69.0"
+    "@sentry/types" "7.69.0"
+    "@sentry/utils" "7.69.0"
+    tslib "^2.4.1 || ^1.9.3"
+
 "@sentry/cli@2.17.5":
   version "2.17.5"
   resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.17.5.tgz#d41e24893a843bcd41e14274044a7ddea9332824"
@@ -3779,6 +3802,17 @@
     proxy-from-env "^1.1.0"
     which "^2.0.2"
 
+"@sentry/cli@2.20.7":
+  version "2.20.7"
+  resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.20.7.tgz#8f7f3f632c330cac6bd2278d820948163f3128a6"
+  integrity sha512-YaHKEUdsFt59nD8yLvuEGCOZ3/ArirL8GZ/66RkZ8wcD2wbpzOFbzo08Kz4te/Eo3OD5/RdW+1dPaOBgGbrXlA==
+  dependencies:
+    https-proxy-agent "^5.0.0"
+    node-fetch "^2.6.7"
+    progress "^2.0.3"
+    proxy-from-env "^1.1.0"
+    which "^2.0.2"
+
 "@sentry/core@7.52.0":
   version "7.52.0"
   resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.52.0.tgz#6c820ca48fe2f06bfd6b290044c96de2375f2ad4"
@@ -3797,6 +3831,15 @@
     "@sentry/utils" "7.52.1"
     tslib "^1.9.3"
 
+"@sentry/core@7.69.0":
+  version "7.69.0"
+  resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.69.0.tgz#ebbe01df573f438f8613107020a4e18eb9adca4d"
+  integrity sha512-V6jvK2lS8bhqZDMFUtvwe2XvNstFQf5A+2LMKCNBOV/NN6eSAAd6THwEpginabjet9dHsNRmMk7WNKvrUfQhZw==
+  dependencies:
+    "@sentry/types" "7.69.0"
+    "@sentry/utils" "7.69.0"
+    tslib "^2.4.1 || ^1.9.3"
+
 "@sentry/hub@7.52.0":
   version "7.52.0"
   resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-7.52.0.tgz#ffc087d58c745d57108862faa0f701b15503dcc2"
@@ -3807,6 +3850,16 @@
     "@sentry/utils" "7.52.0"
     tslib "^1.9.3"
 
+"@sentry/hub@7.69.0":
+  version "7.69.0"
+  resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-7.69.0.tgz#3ef3b98e1810b05cb4fb37a861bd700ef592a2a9"
+  integrity sha512-71TQ7P5de9+cdW1ETGI9wgi2VNqfyWaM3cnUvheXaSjPRBrr6mhwoaSjo+GGsiwx97Ob9DESZEIhdzcLupzkFA==
+  dependencies:
+    "@sentry/core" "7.69.0"
+    "@sentry/types" "7.69.0"
+    "@sentry/utils" "7.69.0"
+    tslib "^2.4.1 || ^1.9.3"
+
 "@sentry/integrations@7.52.0":
   version "7.52.0"
   resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.52.0.tgz#632aa5e54bdfdab910a24057c2072634a2670409"
@@ -3827,6 +3880,30 @@
     localforage "^1.8.1"
     tslib "^1.9.3"
 
+"@sentry/integrations@7.69.0":
+  version "7.69.0"
+  resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.69.0.tgz#04c0206d9436ec7b79971e3bde5d6e1e9194595f"
+  integrity sha512-FEFtFqXuCo9+L7bENZxFpEAlIODwHl6FyW/DwLfniy9jOXHU7BhP/oICLrFE5J7rh1gNY7N/8VlaiQr3hCnS/g==
+  dependencies:
+    "@sentry/types" "7.69.0"
+    "@sentry/utils" "7.69.0"
+    localforage "^1.8.1"
+    tslib "^2.4.1 || ^1.9.3"
+
+"@sentry/react-native@5.10.0":
+  version "5.10.0"
+  resolved "https://registry.yarnpkg.com/@sentry/react-native/-/react-native-5.10.0.tgz#b61861276fcb35e69dbe9c4e098ed7c88598f5d9"
+  integrity sha512-YuEZJ3tW5qZlFGFm2FoAZ9vw1fWnjrhMh1IHxo+nUHP3FvVgGkAd/PmSSbgPr2T3YLOIJNiyDdG031Qi7YvtGA==
+  dependencies:
+    "@sentry/browser" "7.69.0"
+    "@sentry/cli" "2.20.7"
+    "@sentry/core" "7.69.0"
+    "@sentry/hub" "7.69.0"
+    "@sentry/integrations" "7.69.0"
+    "@sentry/react" "7.69.0"
+    "@sentry/types" "7.69.0"
+    "@sentry/utils" "7.69.0"
+
 "@sentry/react-native@5.5.0":
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/@sentry/react-native/-/react-native-5.5.0.tgz#b1283f68465b1772ad6059ebba149673cef33f2d"
@@ -3863,6 +3940,17 @@
     hoist-non-react-statics "^3.3.2"
     tslib "^1.9.3"
 
+"@sentry/react@7.69.0":
+  version "7.69.0"
+  resolved "https://registry.yarnpkg.com/@sentry/react/-/react-7.69.0.tgz#b9931ac590d8dad3390a9a03a516f1b1bd75615e"
+  integrity sha512-J+DciRRVuruf1nMmBOi2VeJkOLGeCb4vTOFmHzWTvRJNByZ0flyo8E/fyROL7+23kBq1YbcVY6IloUlH73hneQ==
+  dependencies:
+    "@sentry/browser" "7.69.0"
+    "@sentry/types" "7.69.0"
+    "@sentry/utils" "7.69.0"
+    hoist-non-react-statics "^3.3.2"
+    tslib "^2.4.1 || ^1.9.3"
+
 "@sentry/replay@7.52.0":
   version "7.52.0"
   resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.52.0.tgz#4d78e88282d2c1044ea4b648a68d1b22173e810d"
@@ -3881,6 +3969,15 @@
     "@sentry/types" "7.52.1"
     "@sentry/utils" "7.52.1"
 
+"@sentry/replay@7.69.0":
+  version "7.69.0"
+  resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.69.0.tgz#d727f96292d2b7c25df022fa53764fd39910fcda"
+  integrity sha512-oUqWyBPFUgShdVvgJtV65EQH9pVDmoYVQMOu59JI6FHVeL3ald7R5Mvz6GaNLXsirvvhp0yAkcAd2hc5Xi6hDw==
+  dependencies:
+    "@sentry/core" "7.69.0"
+    "@sentry/types" "7.69.0"
+    "@sentry/utils" "7.69.0"
+
 "@sentry/types@7.52.0":
   version "7.52.0"
   resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.52.0.tgz#b7d5372f17355e3991cbe818ad567f3fe277cc6b"
@@ -3891,6 +3988,11 @@
   resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.52.1.tgz#bcff6d0462d9b9b7b9ec31c0068fe02d44f25da2"
   integrity sha512-OMbGBPrJsw0iEXwZ2bJUYxewI1IEAU2e1aQGc0O6QW5+6hhCh+8HO8Xl4EymqwejjztuwStkl6G1qhK+Q0/Row==
 
+"@sentry/types@7.69.0":
+  version "7.69.0"
+  resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.69.0.tgz#012b8d90d270a473cc2a5cf58a56870542739292"
+  integrity sha512-zPyCox0mzitzU6SIa1KIbNoJAInYDdUpdiA+PoUmMn2hFMH1llGU/cS7f4w/mAsssTlbtlBi72RMnWUCy578bw==
+
 "@sentry/utils@7.52.0":
   version "7.52.0"
   resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.52.0.tgz#cacc36d905036ba7084c14965e964fc44239d7f0"
@@ -3907,6 +4009,14 @@
     "@sentry/types" "7.52.1"
     tslib "^1.9.3"
 
+"@sentry/utils@7.69.0":
+  version "7.69.0"
+  resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.69.0.tgz#b7594e4eb2a88b9b25298770b841dd3f81bd2aa4"
+  integrity sha512-4eBixe5Y+0EGVU95R4NxH3jkkjtkE4/CmSZD4In8SCkWGSauogePtq6hyiLsZuP1QHdpPb9Kt0+zYiBb2LouBA==
+  dependencies:
+    "@sentry/types" "7.69.0"
+    tslib "^2.4.1 || ^1.9.3"
+
 "@sideway/address@^4.1.3":
   version "4.1.4"
   resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.4.tgz#03dccebc6ea47fdc226f7d3d1ad512955d4783f0"
@@ -6532,6 +6642,11 @@ babel-plugin-transform-react-remove-prop-types@^0.4.24:
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz#f2edaf9b4c6a5fbe5c1d678bfb531078c1555f3a"
   integrity sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA==
 
+babel-plugin-transform-remove-console@^6.9.4:
+  version "6.9.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz#b980360c067384e24b357a588d807d3c83527780"
+  integrity sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==
+
 babel-preset-current-node-syntax@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz#b4399239b89b2a011f9ddbe3e4f401fc40cff73b"
@@ -8135,10 +8250,10 @@ detect-port-alt@^1.1.6:
     address "^1.0.1"
     debug "^2.6.0"
 
-detox@^20.11.3:
-  version "20.11.3"
-  resolved "https://registry.yarnpkg.com/detox/-/detox-20.11.3.tgz#56d5ea869977f5a747e1be0901b279ab953f8b7b"
-  integrity sha512-kdoRAtDLFxXpjt1QlniI+WryMtf7Y8mrZ33Ql8cTR9qoCS/CThi4pweYAQm8yUPqAv1ZtT3eIm3EzRwjEosgLA==
+detox@^20.13.0:
+  version "20.13.0"
+  resolved "https://registry.yarnpkg.com/detox/-/detox-20.13.0.tgz#923111638dfdb16089eea4f07bf4f0b56468d097"
+  integrity sha512-p9MUcoHWFTqSDaoaN+/hnJYdzNYqdelUr/sxzy3zLoS/qehnVJv2yG9pYqz/+gKpJaMIpw2+TVw9imdAx5JpaA==
   dependencies:
     ajv "^8.6.3"
     bunyan "^1.8.12"
@@ -16704,7 +16819,7 @@ send@0.18.0, send@^0.18.0:
     range-parser "~1.2.1"
     statuses "2.0.1"
 
-sentry-expo@~7.0.0:
+sentry-expo@~7.0.1:
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/sentry-expo/-/sentry-expo-7.0.1.tgz#025f0e90ab7f7cba1e00c892fabc027de21bc5bc"
   integrity sha512-8vmOy4R+qM1peQA9EP8rDGUMBhgMU1D5FyuWY9kfNGatmWuvEmlZpVgaXoXaNPIhPgf2TMrvQIlbqLHtTkoeSA==
@@ -17890,7 +18005,7 @@ tslib@^1.8.1, tslib@^1.9.3:
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
-tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.4.1:
+tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.4.1, "tslib@^2.4.1 || ^1.9.3":
   version "2.6.2"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
   integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
@@ -19191,10 +19306,10 @@ yocto-queue@^1.0.0:
   resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
   integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
 
-zeed-dom@^0.9.19:
-  version "0.9.26"
-  resolved "https://registry.yarnpkg.com/zeed-dom/-/zeed-dom-0.9.26.tgz#f0127d1024b34a1233a321bd6d0275b3ba998b30"
-  integrity sha512-HWjX8rA3Y/RI32zby3KIN1D+mgskce+She4K7kRyyx62OiVxJ5FnYm8vWq0YVAja3Tf2S1M0XAc6O2lRFcMgcQ==
+zeed-dom@0.10.9, zeed-dom@^0.9.19:
+  version "0.10.9"
+  resolved "https://registry.yarnpkg.com/zeed-dom/-/zeed-dom-0.10.9.tgz#b3eb5d9b7cf1be17e1fb3a708379df5edce195be"
+  integrity sha512-qQQ7Wu7IJ3Vo/LjeKWj97A2Hi17di4ZdmgNZj6AWbDbpt3hvO4EMfjYVA2/2unLYT+XpmMq5fqaLqCeU7Im83A==
   dependencies:
     css-what "^6.1.0"