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.md27
-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.js6
-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.json26
-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+0.72.5.patch (renamed from patches/react-native+0.72.4.patch)0
-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/api/index.ts2
-rw-r--r--src/lib/constants.ts13
-rw-r--r--src/lib/hooks/useFollowProfile.ts (renamed from src/lib/hooks/useFollowDid.ts)24
-rw-r--r--src/lib/hooks/useMinimalShellMode.tsx72
-rw-r--r--src/lib/strings/url-helpers.ts27
-rw-r--r--src/lib/themes.ts4
-rw-r--r--src/state/models/cache/my-follows.ts104
-rw-r--r--src/state/models/content/profile.ts4
-rw-r--r--src/state/models/discovery/foafs.ts36
-rw-r--r--src/state/models/discovery/onboarding.ts1
-rw-r--r--src/state/models/discovery/suggested-actors.ts4
-rw-r--r--src/state/models/discovery/user-autocomplete.ts115
-rw-r--r--src/state/models/feeds/post.ts14
-rw-r--r--src/state/models/feeds/posts.ts8
-rw-r--r--src/state/models/invited-users.ts2
-rw-r--r--src/state/models/lists/likes.ts2
-rw-r--r--src/state/models/lists/reposted-by.ts2
-rw-r--r--src/state/models/lists/user-followers.ts2
-rw-r--r--src/state/models/lists/user-follows.ts2
-rw-r--r--src/state/models/media/image.ts2
-rw-r--r--src/state/models/ui/preferences.ts111
-rw-r--r--src/state/models/ui/reminders.e2e.ts24
-rw-r--r--src/state/models/ui/reminders.ts19
-rw-r--r--src/state/models/ui/search.ts2
-rw-r--r--src/view/com/auth/onboarding/RecommendedFeeds.tsx2
-rw-r--r--src/view/com/auth/onboarding/RecommendedFeedsItem.tsx1
-rw-r--r--src/view/com/auth/onboarding/RecommendedFollowsItem.tsx2
-rw-r--r--src/view/com/composer/Composer.tsx3
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx23
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx2
-rw-r--r--src/view/com/feeds/FeedPage.tsx210
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx467
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx302
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx6
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts47
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts150
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts41
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts32
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts25
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts431
-rw-r--r--src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts24
-rw-r--r--src/view/com/lightbox/ImageViewing/index.tsx190
-rw-r--r--src/view/com/lightbox/ImageViewing/transforms.ts98
-rw-r--r--src/view/com/lightbox/ImageViewing/utils.ts139
-rw-r--r--src/view/com/lightbox/Lightbox.tsx170
-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/Modal.tsx20
-rw-r--r--src/view/com/modals/ProfilePreview.tsx11
-rw-r--r--src/view/com/modals/SwitchAccount.tsx113
-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/notifications/InvitedUsers.tsx2
-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/post-thread/PostThread.tsx4
-rw-r--r--src/view/com/posts/Feed.tsx19
-rw-r--r--src/view/com/posts/FollowingEmptyState.tsx98
-rw-r--r--src/view/com/posts/FollowingEndOfFeed.tsx100
-rw-r--r--src/view/com/profile/FollowButton.tsx9
-rw-r--r--src/view/com/profile/ProfileCard.tsx2
-rw-r--r--src/view/com/profile/ProfileHeader.tsx19
-rw-r--r--src/view/com/profile/ProfileHeaderSuggestedFollows.tsx22
-rw-r--r--src/view/com/search/HeaderWithInput.tsx2
-rw-r--r--src/view/com/util/EmptyState.tsx3
-rw-r--r--src/view/com/util/ErrorBoundary.tsx2
-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/AutoSizedImage.tsx12
-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.tsx213
-rw-r--r--src/view/screens/Notifications.tsx1
-rw-r--r--src/view/screens/SearchMobile.tsx12
-rw-r--r--src/view/screens/Support.tsx10
-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/Search.tsx11
-rw-r--r--src/view/shell/index.tsx7
-rw-r--r--yarn.lock635
115 files changed, 3151 insertions, 2873 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 c32d726e4..4f7d00ebb 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,33 @@
 # Bluesky Social App
 
-Welcome friends! This is the codebase for the Bluesky Social app. It serves as a resource to engineers building on the [AT Protocol](https://atproto.com).
+Welcome friends! This is the codebase for the Bluesky Social app.
+
+Get the app itself:
 
 - **Web: [bsky.app](https://bsky.app)**
 - **iOS: [App Store](https://apps.apple.com/us/app/bluesky-social/id6444370199)**
 - **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&hl=en_US&gl=US)**
 
-Links:
+## Development Resources
+
+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 amount of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application.
+
+The [Build Instructions](./docs/build.md) are a good place to get started with the app itself.
 
-- [Build instructions](./docs/build.md)
-- [ATProto repo](https://github.com/bluesky-social/atproto)
-- [ATProto docs](https://atproto.com)
+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:
 
-## Rules & guidelines
+- [Overview and Guides](https://atproto.com/guides/overview)
+- [Github Discussions](https://github.com/bluesky-social/atproto/discussions) 👈 Great place to ask questions
+- [Protocol Specifications](https://atproto.com/specs/atp)
+- [Blogpost on self-authenticating data structures](https://blueskyweb.xyz/blog/3-6-2022-a-self-authenticating-social-protocol)
 
---- 
+The Bluesky Social application encompases a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is `app.bsky.*`.
 
-â„šī¸ While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review.
+## Contributions
 
----
+> While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review.
 
 **Rules:**
 
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 51e95b1a1..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.51.0',
+      version: '1.55.0',
       runtimeVersion: {
         policy: 'appVersion',
       },
@@ -19,7 +19,7 @@ module.exports = function () {
         backgroundColor: '#ffffff',
       },
       ios: {
-        buildNumber: '5',
+        buildNumber: '1',
         supportsTablet: false,
         bundleIdentifier: 'xyz.blueskyweb.app',
         config: {
@@ -43,7 +43,7 @@ module.exports = function () {
         backgroundColor: '#ffffff',
       },
       android: {
-        versionCode: 39,
+        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 3f99aea60..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",
@@ -35,7 +41,7 @@
     "@fortawesome/free-regular-svg-icons": "^6.1.1",
     "@fortawesome/free-solid-svg-icons": "^6.1.1",
     "@fortawesome/react-native-fontawesome": "^0.3.0",
-    "@gorhom/bottom-sheet": "^4.4.7",
+    "@gorhom/bottom-sheet": "^4.5.1",
     "@mattermost/react-native-paste-input": "^0.6.4",
     "@miblanchard/react-native-slider": "^2.3.1",
     "@react-native-async-storage/async-storage": "1.18.2",
@@ -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",
@@ -120,7 +127,7 @@
     "react-avatar-editor": "^13.0.0",
     "react-circular-progressbar": "^2.1.0",
     "react-dom": "^18.2.0",
-    "react-native": "0.72.4",
+    "react-native": "0.72.5",
     "react-native-appstate-hook": "^1.0.6",
     "react-native-draggable-flatlist": "^4.0.1",
     "react-native-drawer-layout": "^3.2.0",
@@ -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+0.72.4.patch b/patches/react-native+0.72.5.patch
index d640f6c9e..d640f6c9e 100644
--- a/patches/react-native+0.72.4.patch
+++ b/patches/react-native+0.72.5.patch
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/api/index.ts b/src/lib/api/index.ts
index 8a9389a18..f930bd7b1 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -94,7 +94,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     | undefined
   let reply
   let rt = new RichText(
-    {text: opts.rawText.trim()},
+    {text: opts.rawText.trimEnd()},
     {
       cleanNewlines: true,
     },
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 1a7949e6a..81a6d4e77 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -79,6 +79,7 @@ export async function DEFAULT_FEEDS(
   serviceUrl: string,
   resolveHandle: (name: string) => Promise<string>,
 ) {
+  // TODO: remove this when the test suite no longer relies on it
   if (IS_LOCAL_DEV(serviceUrl)) {
     // local dev
     const aliceDid = await resolveHandle('alice.test')
@@ -106,16 +107,8 @@ export async function DEFAULT_FEEDS(
   } else {
     // production
     return {
-      pinned: [
-        PROD_DEFAULT_FEED('whats-hot'),
-        PROD_DEFAULT_FEED('with-friends'),
-      ],
-      saved: [
-        PROD_DEFAULT_FEED('bsky-team'),
-        PROD_DEFAULT_FEED('with-friends'),
-        PROD_DEFAULT_FEED('whats-hot'),
-        PROD_DEFAULT_FEED('hot-classic'),
-      ],
+      pinned: [PROD_DEFAULT_FEED('whats-hot')],
+      saved: [PROD_DEFAULT_FEED('whats-hot')],
     }
   }
 }
diff --git a/src/lib/hooks/useFollowDid.ts b/src/lib/hooks/useFollowProfile.ts
index 223adb047..6220daba8 100644
--- a/src/lib/hooks/useFollowDid.ts
+++ b/src/lib/hooks/useFollowProfile.ts
@@ -1,11 +1,11 @@
 import React from 'react'
-
+import {AppBskyActorDefs} from '@atproto/api'
 import {useStores} from 'state/index'
 import {FollowState} from 'state/models/cache/my-follows'
 
-export function useFollowDid({did}: {did: string}) {
+export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
   const store = useStores()
-  const state = store.me.follows.getFollowState(did)
+  const state = store.me.follows.getFollowState(profile.did)
 
   return {
     state,
@@ -13,8 +13,10 @@ export function useFollowDid({did}: {did: string}) {
     toggle: React.useCallback(async () => {
       if (state === FollowState.Following) {
         try {
-          await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
-          store.me.follows.removeFollow(did)
+          await store.agent.deleteFollow(
+            store.me.follows.getFollowUri(profile.did),
+          )
+          store.me.follows.removeFollow(profile.did)
           return {
             state: FollowState.NotFollowing,
             following: false,
@@ -25,8 +27,14 @@ export function useFollowDid({did}: {did: string}) {
         }
       } else if (state === FollowState.NotFollowing) {
         try {
-          const res = await store.agent.follow(did)
-          store.me.follows.addFollow(did, res.uri)
+          const res = await store.agent.follow(profile.did)
+          store.me.follows.addFollow(profile.did, {
+            followRecordUri: res.uri,
+            did: profile.did,
+            handle: profile.handle,
+            displayName: profile.displayName,
+            avatar: profile.avatar,
+          })
           return {
             state: FollowState.Following,
             following: true,
@@ -41,6 +49,6 @@ export function useFollowDid({did}: {did: string}) {
         state: FollowState.Unknown,
         following: false,
       }
-    }, [store, did, state]),
+    }, [store, profile, state]),
   }
 }
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/lib/themes.ts b/src/lib/themes.ts
index 95aee0842..b778d5b30 100644
--- a/src/lib/themes.ts
+++ b/src/lib/themes.ts
@@ -264,8 +264,8 @@ export const defaultTheme: Theme = {
       fontWeight: '400',
     },
     'post-text-lg': {
-      fontSize: 22,
-      letterSpacing: 0.4,
+      fontSize: 20,
+      letterSpacing: 0.2,
       fontWeight: '400',
     },
     'button-lg': {
diff --git a/src/state/models/cache/my-follows.ts b/src/state/models/cache/my-follows.ts
index 10f88c4a9..e1e8af509 100644
--- a/src/state/models/cache/my-follows.ts
+++ b/src/state/models/cache/my-follows.ts
@@ -1,6 +1,14 @@
 import {makeAutoObservable} from 'mobx'
-import {AppBskyActorDefs} from '@atproto/api'
+import {
+  AppBskyActorDefs,
+  AppBskyGraphGetFollows as GetFollows,
+  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
 
 type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView
 
@@ -10,6 +18,14 @@ export enum FollowState {
   Unknown,
 }
 
+export interface FollowInfo {
+  did: string
+  followRecordUri: string | undefined
+  handle: string
+  displayName: string | undefined
+  avatar: string | undefined
+}
+
 /**
  * This model is used to maintain a synced local cache of the user's
  * follows. It should be periodically refreshed and updated any time
@@ -17,9 +33,8 @@ export enum FollowState {
  */
 export class MyFollowsCache {
   // data
-  followDidToRecordMap: Record<string, string | boolean> = {}
+  byDid: Record<string, FollowInfo> = {}
   lastSync = 0
-  myDid?: string
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(
@@ -35,16 +50,45 @@ export class MyFollowsCache {
   // =
 
   clear() {
-    this.followDidToRecordMap = {}
-    this.lastSync = 0
-    this.myDid = undefined
+    this.byDid = {}
   }
 
+  /**
+   * Syncs a subset of the user's follows
+   * for performance reasons, caps out at 1000 follows
+   */
+  syncIfNeeded = bundleAsync(async () => {
+    if (this.lastSync > Date.now() - SYNC_TTL) {
+      return
+    }
+
+    let cursor
+    for (let i = 0; i < MAX_SYNC_PAGES; i++) {
+      const res: GetFollows.Response = await this.rootStore.agent.getFollows({
+        actor: this.rootStore.me.did,
+        cursor,
+        limit: 100,
+      })
+      res.data.follows = res.data.follows.filter(
+        profile =>
+          !moderateProfile(profile, this.rootStore.preferences.moderationOpts)
+            .account.filter,
+      )
+      this.hydrateMany(res.data.follows)
+      if (!res.data.cursor) {
+        break
+      }
+      cursor = res.data.cursor
+    }
+
+    this.lastSync = Date.now()
+  })
+
   getFollowState(did: string): FollowState {
-    if (typeof this.followDidToRecordMap[did] === 'undefined') {
+    if (typeof this.byDid[did] === 'undefined') {
       return FollowState.Unknown
     }
-    if (typeof this.followDidToRecordMap[did] === 'string') {
+    if (typeof this.byDid[did].followRecordUri === 'string') {
       return FollowState.Following
     }
     return FollowState.NotFollowing
@@ -53,49 +97,41 @@ export class MyFollowsCache {
   async fetchFollowState(did: string): Promise<FollowState> {
     // TODO: can we get a more efficient method for this? getProfile fetches more data than we need -prf
     const res = await this.rootStore.agent.getProfile({actor: did})
-    if (res.data.viewer?.following) {
-      this.addFollow(did, res.data.viewer.following)
-    } else {
-      this.removeFollow(did)
-    }
+    this.hydrate(did, res.data)
     return this.getFollowState(did)
   }
 
   getFollowUri(did: string): string {
-    const v = this.followDidToRecordMap[did]
-    if (typeof v === 'string') {
-      return v
+    const v = this.byDid[did]
+    if (v && typeof v.followRecordUri === 'string') {
+      return v.followRecordUri
     }
     throw new Error('Not a followed user')
   }
 
-  addFollow(did: string, recordUri: string) {
-    this.followDidToRecordMap[did] = recordUri
+  addFollow(did: string, info: FollowInfo) {
+    this.byDid[did] = info
   }
 
   removeFollow(did: string) {
-    this.followDidToRecordMap[did] = false
+    if (this.byDid[did]) {
+      this.byDid[did].followRecordUri = undefined
+    }
   }
 
-  /**
-   * Use this to incrementally update the cache as views provide information
-   */
-  hydrate(did: string, recordUri: string | undefined) {
-    if (recordUri) {
-      this.followDidToRecordMap[did] = recordUri
-    } else {
-      this.followDidToRecordMap[did] = false
+  hydrate(did: string, profile: Profile) {
+    this.byDid[did] = {
+      did,
+      followRecordUri: profile.viewer?.following,
+      handle: profile.handle,
+      displayName: profile.displayName,
+      avatar: profile.avatar,
     }
   }
 
-  /**
-   * Use this to incrementally update the cache as views provide information
-   */
-  hydrateProfiles(profiles: Profile[]) {
+  hydrateMany(profiles: Profile[]) {
     for (const profile of profiles) {
-      if (profile.viewer) {
-        this.hydrate(profile.did, profile.viewer.following)
-      }
+      this.hydrate(profile.did, profile)
     }
   }
 }
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index 26fa6008c..906f84c28 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -137,7 +137,7 @@ export class ProfileModel {
       runInAction(() => {
         this.followersCount++
         this.viewer.following = res.uri
-        this.rootStore.me.follows.addFollow(this.did, res.uri)
+        this.rootStore.me.follows.hydrate(this.did, this)
       })
       track('Profile:Follow', {
         username: this.handle,
@@ -290,8 +290,8 @@ export class ProfileModel {
     this.labels = res.data.labels
     if (res.data.viewer) {
       Object.assign(this.viewer, res.data.viewer)
-      this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
     }
+    this.rootStore.me.follows.hydrate(this.did, res.data)
   }
 
   async _createRichText() {
diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts
index 580145f65..4a647dcfe 100644
--- a/src/state/models/discovery/foafs.ts
+++ b/src/state/models/discovery/foafs.ts
@@ -1,8 +1,4 @@
-import {
-  AppBskyActorDefs,
-  AppBskyGraphGetFollows as GetFollows,
-  moderateProfile,
-} from '@atproto/api'
+import {AppBskyActorDefs} from '@atproto/api'
 import {makeAutoObservable, runInAction} from 'mobx'
 import sampleSize from 'lodash.samplesize'
 import {bundleAsync} from 'lib/async/bundle'
@@ -43,35 +39,13 @@ export class FoafsModel {
     try {
       this.isLoading = true
 
-      // fetch & hydrate up to 1000 follows
-      {
-        let cursor
-        for (let i = 0; i < 10; i++) {
-          const res: GetFollows.Response =
-            await this.rootStore.agent.getFollows({
-              actor: this.rootStore.me.did,
-              cursor,
-              limit: 100,
-            })
-          res.data.follows = res.data.follows.filter(
-            profile =>
-              !moderateProfile(
-                profile,
-                this.rootStore.preferences.moderationOpts,
-              ).account.filter,
-          )
-          this.rootStore.me.follows.hydrateProfiles(res.data.follows)
-          if (!res.data.cursor) {
-            break
-          }
-          cursor = res.data.cursor
-        }
-      }
+      // fetch some of the user's follows
+      await this.rootStore.me.follows.syncIfNeeded()
 
       // grab 10 of the users followed by the user
       runInAction(() => {
         this.sources = sampleSize(
-          Object.keys(this.rootStore.me.follows.followDidToRecordMap),
+          Object.keys(this.rootStore.me.follows.byDid),
           10,
         )
       })
@@ -100,7 +74,7 @@ export class FoafsModel {
       for (let i = 0; i < results.length; i++) {
         const res = results[i]
         if (res.status === 'fulfilled') {
-          this.rootStore.me.follows.hydrateProfiles(res.value.data.follows)
+          this.rootStore.me.follows.hydrateMany(res.value.data.follows)
         }
         const profile = profiles.data.profiles[i]
         const source = this.sources[i]
diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts
index 8ad321ed9..3638e7f0d 100644
--- a/src/state/models/discovery/onboarding.ts
+++ b/src/state/models/discovery/onboarding.ts
@@ -81,6 +81,7 @@ export class OnboardingModel {
   }
 
   finish() {
+    this.rootStore.me.mainFeed.refresh() // load the selected content
     this.step = 'Home'
     track('Onboarding:Complete')
   }
diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts
index afa5e74e3..d270267ee 100644
--- a/src/state/models/discovery/suggested-actors.ts
+++ b/src/state/models/discovery/suggested-actors.ts
@@ -76,7 +76,7 @@ export class SuggestedActorsModel {
           !moderateProfile(actor, this.rootStore.preferences.moderationOpts)
             .account.filter,
       )
-      this.rootStore.me.follows.hydrateProfiles(actors)
+      this.rootStore.me.follows.hydrateMany(actors)
 
       runInAction(() => {
         if (replace) {
@@ -118,7 +118,7 @@ export class SuggestedActorsModel {
         actor: actor,
       })
     const {suggestions: moreSuggestions} = res.data
-    this.rootStore.me.follows.hydrateProfiles(moreSuggestions)
+    this.rootStore.me.follows.hydrateMany(moreSuggestions)
     // dedupe
     const toInsert = moreSuggestions.filter(
       s => !this.suggestions.find(s2 => s2.did === s.did),
diff --git a/src/state/models/discovery/user-autocomplete.ts b/src/state/models/discovery/user-autocomplete.ts
index 461073e45..25ce859d2 100644
--- a/src/state/models/discovery/user-autocomplete.ts
+++ b/src/state/models/discovery/user-autocomplete.ts
@@ -4,6 +4,8 @@ import AwaitLock from 'await-lock'
 import {RootStoreModel} from '../root-store'
 import {isInvalidHandle} from 'lib/strings/handles'
 
+type ProfileViewBasic = AppBskyActorDefs.ProfileViewBasic
+
 export class UserAutocompleteModel {
   // state
   isLoading = false
@@ -12,9 +14,8 @@ export class UserAutocompleteModel {
   lock = new AwaitLock()
 
   // data
-  follows: AppBskyActorDefs.ProfileViewBasic[] = []
-  searchRes: AppBskyActorDefs.ProfileViewBasic[] = []
   knownHandles: Set<string> = new Set()
+  _suggestions: ProfileViewBasic[] = []
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(
@@ -27,29 +28,35 @@ export class UserAutocompleteModel {
     )
   }
 
-  get suggestions() {
+  get follows(): ProfileViewBasic[] {
+    return Object.values(this.rootStore.me.follows.byDid).map(item => ({
+      did: item.did,
+      handle: item.handle,
+      displayName: item.displayName,
+      avatar: item.avatar,
+    }))
+  }
+
+  get suggestions(): ProfileViewBasic[] {
     if (!this.isActive) {
       return []
     }
-    if (this.prefix) {
-      return this.searchRes.map(user => ({
-        handle: user.handle,
-        displayName: user.displayName,
-        avatar: user.avatar,
-      }))
-    }
-    return this.follows.map(follow => ({
-      handle: follow.handle,
-      displayName: follow.displayName,
-      avatar: follow.avatar,
-    }))
+    return this._suggestions
   }
 
   // public api
   // =
 
   async setup() {
-    await this._getFollows()
+    await this.rootStore.me.follows.syncIfNeeded()
+    runInAction(() => {
+      for (const did in this.rootStore.me.follows.byDid) {
+        const info = this.rootStore.me.follows.byDid[did]
+        if (!isInvalidHandle(info.handle)) {
+          this.knownHandles.add(info.handle)
+        }
+      }
+    })
   }
 
   setActive(v: boolean) {
@@ -57,7 +64,7 @@ export class UserAutocompleteModel {
   }
 
   async setPrefix(prefix: string) {
-    const origPrefix = prefix.trim()
+    const origPrefix = prefix.trim().toLocaleLowerCase()
     this.prefix = origPrefix
     await this.lock.acquireAsync()
     try {
@@ -65,9 +72,27 @@ export class UserAutocompleteModel {
         if (this.prefix !== origPrefix) {
           return // another prefix was set before we got our chance
         }
-        await this._search()
+
+        // reset to follow results
+        this._computeSuggestions([])
+
+        // ask backend
+        const res = await this.rootStore.agent.searchActorsTypeahead({
+          term: this.prefix,
+          limit: 8,
+        })
+        this._computeSuggestions(res.data.actors)
+
+        // update known handles
+        runInAction(() => {
+          for (const u of res.data.actors) {
+            this.knownHandles.add(u.handle)
+          }
+        })
       } else {
-        this.searchRes = []
+        runInAction(() => {
+          this._computeSuggestions([])
+        })
       }
     } finally {
       this.lock.release()
@@ -77,28 +102,40 @@ export class UserAutocompleteModel {
   // internal
   // =
 
-  async _getFollows() {
-    const res = await this.rootStore.agent.getFollows({
-      actor: this.rootStore.me.did || '',
-    })
-    runInAction(() => {
-      this.follows = res.data.follows.filter(f => !isInvalidHandle(f.handle))
-      for (const f of this.follows) {
-        this.knownHandles.add(f.handle)
+  _computeSuggestions(searchRes: AppBskyActorDefs.ProfileViewBasic[] = []) {
+    if (this.prefix) {
+      const items: ProfileViewBasic[] = []
+      for (const item of this.follows) {
+        if (prefixMatch(this.prefix, item)) {
+          items.push(item)
+        }
+        if (items.length >= 8) {
+          break
+        }
       }
-    })
+      for (const item of searchRes) {
+        if (!items.find(item2 => item2.handle === item.handle)) {
+          items.push({
+            did: item.did,
+            handle: item.handle,
+            displayName: item.displayName,
+            avatar: item.avatar,
+          })
+        }
+      }
+      this._suggestions = items
+    } else {
+      this._suggestions = this.follows
+    }
   }
+}
 
-  async _search() {
-    const res = await this.rootStore.agent.searchActorsTypeahead({
-      term: this.prefix,
-      limit: 8,
-    })
-    runInAction(() => {
-      this.searchRes = res.data.actors
-      for (const u of this.searchRes) {
-        this.knownHandles.add(u.handle)
-      }
-    })
+function prefixMatch(prefix: string, info: ProfileViewBasic): boolean {
+  if (info.handle.includes(prefix)) {
+    return true
+  }
+  if (info.displayName?.toLocaleLowerCase().includes(prefix)) {
+    return true
   }
+  return false
 }
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/feeds/posts.ts b/src/state/models/feeds/posts.ts
index bb619147f..2462689b1 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -116,6 +116,10 @@ export class PostsFeedModel {
     return this.hasLoaded && !this.hasContent
   }
 
+  get isLoadingMore() {
+    return this.isLoading && !this.isRefreshing
+  }
+
   setHasNewLatest(v: boolean) {
     this.hasNewLatest = v
   }
@@ -307,12 +311,12 @@ export class PostsFeedModel {
   }
 
   async _appendAll(res: FeedAPIResponse, replace = false) {
-    this.hasMore = !!res.cursor
+    this.hasMore = !!res.cursor && res.feed.length > 0
     if (replace) {
       this.emptyFetches = 0
     }
 
-    this.rootStore.me.follows.hydrateProfiles(
+    this.rootStore.me.follows.hydrateMany(
       res.feed.map(item => item.post.author),
     )
     for (const item of res.feed) {
diff --git a/src/state/models/invited-users.ts b/src/state/models/invited-users.ts
index a28e0309a..cd3667062 100644
--- a/src/state/models/invited-users.ts
+++ b/src/state/models/invited-users.ts
@@ -61,7 +61,7 @@ export class InvitedUsers {
             profile => !profile.viewer?.following,
           )
         })
-        this.rootStore.me.follows.hydrateProfiles(this.profiles)
+        this.rootStore.me.follows.hydrateMany(this.profiles)
       } catch (e) {
         this.rootStore.log.error(
           'Failed to fetch profiles for invited users',
diff --git a/src/state/models/lists/likes.ts b/src/state/models/lists/likes.ts
index 39882d73a..dd3cf18a3 100644
--- a/src/state/models/lists/likes.ts
+++ b/src/state/models/lists/likes.ts
@@ -126,7 +126,7 @@ export class LikesModel {
   _appendAll(res: GetLikes.Response) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
-    this.rootStore.me.follows.hydrateProfiles(
+    this.rootStore.me.follows.hydrateMany(
       res.data.likes.map(like => like.actor),
     )
     this.likes = this.likes.concat(res.data.likes)
diff --git a/src/state/models/lists/reposted-by.ts b/src/state/models/lists/reposted-by.ts
index a70375bdc..5d4fc107d 100644
--- a/src/state/models/lists/reposted-by.ts
+++ b/src/state/models/lists/reposted-by.ts
@@ -130,6 +130,6 @@ export class RepostedByModel {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
     this.repostedBy = this.repostedBy.concat(res.data.repostedBy)
-    this.rootStore.me.follows.hydrateProfiles(res.data.repostedBy)
+    this.rootStore.me.follows.hydrateMany(res.data.repostedBy)
   }
 }
diff --git a/src/state/models/lists/user-followers.ts b/src/state/models/lists/user-followers.ts
index 2962d6242..1f817c33c 100644
--- a/src/state/models/lists/user-followers.ts
+++ b/src/state/models/lists/user-followers.ts
@@ -115,6 +115,6 @@ export class UserFollowersModel {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
     this.followers = this.followers.concat(res.data.followers)
-    this.rootStore.me.follows.hydrateProfiles(res.data.followers)
+    this.rootStore.me.follows.hydrateMany(res.data.followers)
   }
 }
diff --git a/src/state/models/lists/user-follows.ts b/src/state/models/lists/user-follows.ts
index 56432a796..c9630eba8 100644
--- a/src/state/models/lists/user-follows.ts
+++ b/src/state/models/lists/user-follows.ts
@@ -115,6 +115,6 @@ export class UserFollowsModel {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
     this.follows = this.follows.concat(res.data.follows)
-    this.rootStore.me.follows.hydrateProfiles(res.data.follows)
+    this.rootStore.me.follows.hydrateMany(res.data.follows)
   }
 }
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/preferences.ts b/src/state/models/ui/preferences.ts
index b3365bd7c..6ca19b4b7 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -418,6 +418,7 @@ export class PreferencesModel {
     const oldPinned = this.pinnedFeeds
     this.savedFeeds = saved
     this.pinnedFeeds = pinned
+    await this.lock.acquireAsync()
     try {
       const res = await cb()
       runInAction(() => {
@@ -430,6 +431,8 @@ export class PreferencesModel {
         this.pinnedFeeds = oldPinned
       })
       throw e
+    } finally {
+      this.lock.release()
     }
   }
 
@@ -441,7 +444,7 @@ export class PreferencesModel {
 
   async addSavedFeed(v: string) {
     return this._optimisticUpdateSavedFeeds(
-      [...this.savedFeeds, v],
+      [...this.savedFeeds.filter(uri => uri !== v), v],
       this.pinnedFeeds,
       () => this.rootStore.agent.addSavedFeed(v),
     )
@@ -457,8 +460,8 @@ export class PreferencesModel {
 
   async addPinnedFeed(v: string) {
     return this._optimisticUpdateSavedFeeds(
-      this.savedFeeds,
-      [...this.pinnedFeeds, v],
+      [...this.savedFeeds.filter(uri => uri !== v), v],
+      [...this.pinnedFeeds.filter(uri => uri !== v), v],
       () => this.rootStore.agent.addPinnedFeed(v),
     )
   }
@@ -473,71 +476,121 @@ export class PreferencesModel {
 
   async setBirthDate(birthDate: Date) {
     this.birthDate = birthDate
-    await this.rootStore.agent.setPersonalDetails({birthDate})
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setPersonalDetails({birthDate})
+    } finally {
+      this.lock.release()
+    }
   }
 
   async toggleHomeFeedHideReplies() {
     this.homeFeed.hideReplies = !this.homeFeed.hideReplies
-    await this.rootStore.agent.setFeedViewPrefs('home', {
-      hideReplies: this.homeFeed.hideReplies,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setFeedViewPrefs('home', {
+        hideReplies: this.homeFeed.hideReplies,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   async toggleHomeFeedHideRepliesByUnfollowed() {
     this.homeFeed.hideRepliesByUnfollowed =
       !this.homeFeed.hideRepliesByUnfollowed
-    await this.rootStore.agent.setFeedViewPrefs('home', {
-      hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setFeedViewPrefs('home', {
+        hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   async setHomeFeedHideRepliesByLikeCount(threshold: number) {
     this.homeFeed.hideRepliesByLikeCount = threshold
-    await this.rootStore.agent.setFeedViewPrefs('home', {
-      hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setFeedViewPrefs('home', {
+        hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   async toggleHomeFeedHideReposts() {
     this.homeFeed.hideReposts = !this.homeFeed.hideReposts
-    await this.rootStore.agent.setFeedViewPrefs('home', {
-      hideReposts: this.homeFeed.hideReposts,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setFeedViewPrefs('home', {
+        hideReposts: this.homeFeed.hideReposts,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   async toggleHomeFeedHideQuotePosts() {
     this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts
-    await this.rootStore.agent.setFeedViewPrefs('home', {
-      hideQuotePosts: this.homeFeed.hideQuotePosts,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setFeedViewPrefs('home', {
+        hideQuotePosts: this.homeFeed.hideQuotePosts,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   async toggleHomeFeedMergeFeedEnabled() {
     this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled
-    await this.rootStore.agent.setFeedViewPrefs('home', {
-      lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setFeedViewPrefs('home', {
+        lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   async setThreadSort(v: string) {
     if (THREAD_SORT_VALUES.includes(v)) {
       this.thread.sort = v
-      await this.rootStore.agent.setThreadViewPrefs({sort: v})
+      await this.lock.acquireAsync()
+      try {
+        await this.rootStore.agent.setThreadViewPrefs({sort: v})
+      } finally {
+        this.lock.release()
+      }
     }
   }
 
   async togglePrioritizedFollowedUsers() {
     this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers
-    await this.rootStore.agent.setThreadViewPrefs({
-      prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setThreadViewPrefs({
+        prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   async toggleThreadTreeViewEnabled() {
     this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled
-    await this.rootStore.agent.setThreadViewPrefs({
-      lab_treeViewEnabled: this.thread.lab_treeViewEnabled,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setThreadViewPrefs({
+        lab_treeViewEnabled: this.thread.lab_treeViewEnabled,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   toggleRequireAltTextEnabled() {
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 f8becdec3..c650de004 100644
--- a/src/state/models/ui/reminders.ts
+++ b/src/state/models/ui/reminders.ts
@@ -3,14 +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 {
-  // NOTE
-  // by defaulting to the current date, we ensure that the user won't be nagged
-  // on first run (aka right after creating an account)
-  // -prf
-  lastEmailConfirm: Date = new Date()
+  lastEmailConfirm: Date | null = null
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(
@@ -46,6 +40,13 @@ export class Reminders {
     if (sess.emailConfirmed) {
       return false
     }
+    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
@@ -54,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/state/models/ui/search.ts b/src/state/models/ui/search.ts
index 4ab9db513..2b2036751 100644
--- a/src/state/models/ui/search.ts
+++ b/src/state/models/ui/search.ts
@@ -59,7 +59,7 @@ export class SearchUIModel {
       } while (profilesSearch.length)
     }
 
-    this.rootStore.me.follows.hydrateProfiles(profiles)
+    this.rootStore.me.follows.hydrateMany(profiles)
 
     runInAction(() => {
       this.profiles = profiles
diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
index 24fc9eef1..aaba19c80 100644
--- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
@@ -65,7 +65,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
           tdStyles.title2,
           isTabletOrMobile && tdStyles.title2Small,
         ]}>
-        Recomended
+        Recommended
       </Text>
       <Text
         style={[
diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
index d130dc138..6796c64db 100644
--- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
@@ -30,7 +30,6 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({
       }
     } else {
       try {
-        await item.save()
         await item.pin()
       } catch (e) {
         Toast.show('There was an issue contacting your server')
diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
index 51e3bc382..2b26918d0 100644
--- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
@@ -89,7 +89,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
         </View>
 
         <FollowButton
-          did={profile.did}
+          profile={profile}
           labelStyle={styles.followButton}
           onToggleFollow={async isFollow => {
             if (isFollow) {
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index f7b657272..e44a0ce01 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -131,6 +131,9 @@ export const ComposePost = observer(function ComposePost({
   }, [store, onClose, graphemeLength, gallery])
   // android back button
   useEffect(() => {
+    if (!isAndroid) {
+      return
+    }
     const backHandler = BackHandler.addEventListener(
       'hardwareBackPress',
       () => {
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/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 31e372567..35482bc70 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -119,7 +119,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
       onUpdate({editor: editorProp}) {
         const json = editorProp.getJSON()
 
-        const newRt = new RichText({text: editorJsonToText(json).trim()})
+        const newRt = new RichText({text: editorJsonToText(json).trimEnd()})
         newRt.detectFacetsWithoutResolution()
         setRichText(newRt)
 
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 f5e858209..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,157 +1,386 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
+import React, {useState} from 'react'
 
-import React, {useCallback, useRef, useState} from 'react'
-
-import {
-  Animated,
-  ScrollView,
-  Dimensions,
-  StyleSheet,
-  NativeScrollEvent,
-  NativeSyntheticEvent,
-  NativeMethodsMixin,
-} from 'react-native'
+import {ActivityIndicator, Dimensions, StyleSheet} from 'react-native'
 import {Image} from 'expo-image'
-
+import Animated, {
+  runOnJS,
+  useAnimatedRef,
+  useAnimatedStyle,
+  useAnimatedReaction,
+  useSharedValue,
+  withDecay,
+  withSpring,
+} from 'react-native-reanimated'
+import {GestureDetector, Gesture} from 'react-native-gesture-handler'
 import useImageDimensions from '../../hooks/useImageDimensions'
-import usePanResponder from '../../hooks/usePanResponder'
-
-import {getImageStyles, getImageTransform} from '../../utils'
-import {ImageSource} from '../../@types'
-import {ImageLoading} from './ImageLoading'
+import {
+  createTransform,
+  readTransform,
+  applyRounding,
+  prependPan,
+  prependPinch,
+  prependTransform,
+  TransformMatrix,
+} from '../../transforms'
+import type {ImageSource, Dimensions as ImageDimensions} from '../../@types'
 
-const SWIPE_CLOSE_OFFSET = 75
-const SWIPE_CLOSE_VELOCITY = 1.75
 const SCREEN = Dimensions.get('window')
-const SCREEN_WIDTH = SCREEN.width
-const SCREEN_HEIGHT = SCREEN.height
+const MIN_DOUBLE_TAP_SCALE = 2
+const MAX_ORIGINAL_IMAGE_ZOOM = 2
+
+const AnimatedImage = Animated.createAnimatedComponent(Image)
+const initialTransform = createTransform()
 
 type Props = {
   imageSrc: ImageSource
   onRequestClose: () => void
+  onTap: () => void
   onZoom: (isZoomed: boolean) => void
-  onLongPress: (image: ImageSource) => void
-  delayLongPress: number
-  swipeToCloseEnabled?: boolean
-  doubleTapToZoomEnabled?: boolean
+  isScrollViewBeingDragged: boolean
 }
-
-const AnimatedImage = Animated.createAnimatedComponent(Image)
-
 const ImageItem = ({
   imageSrc,
+  onTap,
   onZoom,
   onRequestClose,
-  onLongPress,
-  delayLongPress,
-  swipeToCloseEnabled = true,
-  doubleTapToZoomEnabled = true,
+  isScrollViewBeingDragged,
 }: Props) => {
-  const imageContainer = useRef<ScrollView & NativeMethodsMixin>(null)
+  const [isScaled, setIsScaled] = useState(false)
+  const [isLoaded, setIsLoaded] = useState(false)
   const imageDimensions = useImageDimensions(imageSrc)
-  const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
-  const scrollValueY = new Animated.Value(0)
-  const [isLoaded, setLoadEnd] = useState(false)
-
-  const onLoaded = useCallback(() => setLoadEnd(true), [])
-  const onZoomPerformed = useCallback(
-    (isZoomed: boolean) => {
-      onZoom(isZoomed)
-      if (imageContainer?.current) {
-        imageContainer.current.setNativeProps({
-          scrollEnabled: !isZoomed,
-        })
+  const committedTransform = useSharedValue(initialTransform)
+  const panTranslation = useSharedValue({x: 0, y: 0})
+  const pinchOrigin = useSharedValue({x: 0, y: 0})
+  const pinchScale = useSharedValue(1)
+  const pinchTranslation = useSharedValue({x: 0, y: 0})
+  const dismissSwipeTranslateY = useSharedValue(0)
+  const containerRef = useAnimatedRef()
+
+  // Keep track of when we're entering or leaving scaled rendering.
+  // Note: DO NOT move any logic reading animated values outside this function.
+  useAnimatedReaction(
+    () => {
+      if (pinchScale.value !== 1) {
+        // We're currently pinching.
+        return true
+      }
+      const [, , committedScale] = readTransform(committedTransform.value)
+      if (committedScale !== 1) {
+        // We started from a pinched in state.
+        return true
+      }
+      // We're at rest.
+      return false
+    },
+    (nextIsScaled, prevIsScaled) => {
+      if (nextIsScaled !== prevIsScaled) {
+        runOnJS(handleZoom)(nextIsScaled)
       }
     },
-    [onZoom],
   )
 
-  const onLongPressHandler = useCallback(() => {
-    onLongPress(imageSrc)
-  }, [imageSrc, onLongPress])
-
-  const [panHandlers, scaleValue, translateValue] = usePanResponder({
-    initialScale: scale || 1,
-    initialTranslate: translate || {x: 0, y: 0},
-    onZoom: onZoomPerformed,
-    doubleTapToZoomEnabled,
-    onLongPress: onLongPressHandler,
-    delayLongPress,
-  })
+  function handleZoom(nextIsScaled: boolean) {
+    setIsScaled(nextIsScaled)
+    onZoom(nextIsScaled)
+  }
 
-  const imagesStyles = getImageStyles(
-    imageDimensions,
-    translateValue,
-    scaleValue,
-  )
-  const imageOpacity = scrollValueY.interpolate({
-    inputRange: [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET],
-    outputRange: [0.7, 1, 0.7],
+  const animatedStyle = useAnimatedStyle(() => {
+    // Apply the active adjustments on top of the committed transform before the gestures.
+    // This is matrix multiplication, so operations are applied in the reverse order.
+    let t = createTransform()
+    prependPan(t, panTranslation.value)
+    prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value)
+    prependTransform(t, committedTransform.value)
+    const [translateX, translateY, scale] = readTransform(t)
+
+    const dismissDistance = dismissSwipeTranslateY.value
+    const dismissProgress = Math.min(
+      Math.abs(dismissDistance) / (SCREEN.height / 2),
+      1,
+    )
+    return {
+      opacity: 1 - dismissProgress,
+      transform: [
+        {translateX},
+        {translateY: translateY + dismissDistance},
+        {scale},
+      ],
+    }
   })
-  const imageStylesWithOpacity = {...imagesStyles, opacity: imageOpacity}
-
-  const onScrollEndDrag = ({
-    nativeEvent,
-  }: NativeSyntheticEvent<NativeScrollEvent>) => {
-    const velocityY = nativeEvent?.velocity?.y ?? 0
-    const offsetY = nativeEvent?.contentOffset?.y ?? 0
-
-    if (
-      (Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY &&
-        offsetY > SWIPE_CLOSE_OFFSET) ||
-      offsetY > SCREEN_HEIGHT / 2
-    ) {
-      onRequestClose()
+
+  // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges.
+  // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
+  function getExtraTranslationToStayInBounds(
+    candidateTransform: TransformMatrix,
+  ) {
+    'worklet'
+    if (!imageDimensions) {
+      return [0, 0]
     }
+    const [nextTranslateX, nextTranslateY, nextScale] =
+      readTransform(candidateTransform)
+    const scaledDimensions = getScaledDimensions(imageDimensions, nextScale)
+    const clampedTranslateX = clampTranslation(
+      nextTranslateX,
+      scaledDimensions.width,
+      SCREEN.width,
+    )
+    const clampedTranslateY = clampTranslation(
+      nextTranslateY,
+      scaledDimensions.height,
+      SCREEN.height,
+    )
+    const dx = clampedTranslateX - nextTranslateX
+    const dy = clampedTranslateY - nextTranslateY
+    return [dx, dy]
   }
 
-  const onScroll = ({nativeEvent}: NativeSyntheticEvent<NativeScrollEvent>) => {
-    const offsetY = nativeEvent?.contentOffset?.y ?? 0
+  const pinch = Gesture.Pinch()
+    .onStart(e => {
+      pinchOrigin.value = {
+        x: e.focalX - SCREEN.width / 2,
+        y: e.focalY - SCREEN.height / 2,
+      }
+    })
+    .onChange(e => {
+      if (!imageDimensions) {
+        return
+      }
+      // Don't let the picture zoom in so close that it gets blurry.
+      // Also, like in stock Android apps, don't let the user zoom out further than 1:1.
+      const [, , committedScale] = readTransform(committedTransform.value)
+      const maxCommittedScale =
+        (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
+      const minPinchScale = 1 / committedScale
+      const maxPinchScale = maxCommittedScale / committedScale
+      const nextPinchScale = Math.min(
+        Math.max(minPinchScale, e.scale),
+        maxPinchScale,
+      )
+      pinchScale.value = nextPinchScale
 
-    scrollValueY.setValue(offsetY)
-  }
+      // Zooming out close to the corner could push us out of bounds, which we don't want on Android.
+      // Calculate where we'll end up so we know how much to translate back to stay in bounds.
+      const t = createTransform()
+      prependPan(t, panTranslation.value)
+      prependPinch(t, nextPinchScale, pinchOrigin.value, pinchTranslation.value)
+      prependTransform(t, committedTransform.value)
+      const [dx, dy] = getExtraTranslationToStayInBounds(t)
+      if (dx !== 0 || dy !== 0) {
+        pinchTranslation.value = {
+          x: pinchTranslation.value.x + dx,
+          y: pinchTranslation.value.y + dy,
+        }
+      }
+    })
+    .onEnd(() => {
+      // Commit just the pinch.
+      let t = createTransform()
+      prependPinch(
+        t,
+        pinchScale.value,
+        pinchOrigin.value,
+        pinchTranslation.value,
+      )
+      prependTransform(t, committedTransform.value)
+      applyRounding(t)
+      committedTransform.value = t
+
+      // Reset just the pinch.
+      pinchScale.value = 1
+      pinchOrigin.value = {x: 0, y: 0}
+      pinchTranslation.value = {x: 0, y: 0}
+    })
 
+  const pan = Gesture.Pan()
+    .averageTouches(true)
+    // Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway:
+    .minPointers(isScaled ? 1 : 2)
+    .onChange(e => {
+      if (!imageDimensions) {
+        return
+      }
+      const nextPanTranslation = {x: e.translationX, y: e.translationY}
+      let t = createTransform()
+      prependPan(t, nextPanTranslation)
+      prependPinch(
+        t,
+        pinchScale.value,
+        pinchOrigin.value,
+        pinchTranslation.value,
+      )
+      prependTransform(t, committedTransform.value)
+
+      // Prevent panning from going out of bounds.
+      const [dx, dy] = getExtraTranslationToStayInBounds(t)
+      nextPanTranslation.x += dx
+      nextPanTranslation.y += dy
+      panTranslation.value = nextPanTranslation
+    })
+    .onEnd(() => {
+      // Commit just the pan.
+      let t = createTransform()
+      prependPan(t, panTranslation.value)
+      prependTransform(t, committedTransform.value)
+      applyRounding(t)
+      committedTransform.value = t
+
+      // Reset just the pan.
+      panTranslation.value = {x: 0, y: 0}
+    })
+
+  const singleTap = Gesture.Tap().onEnd(() => {
+    runOnJS(onTap)()
+  })
+
+  const doubleTap = Gesture.Tap()
+    .numberOfTaps(2)
+    .onEnd(e => {
+      if (!imageDimensions) {
+        return
+      }
+      const [, , committedScale] = readTransform(committedTransform.value)
+      if (committedScale !== 1) {
+        // Go back to 1:1 using the identity vector.
+        let t = createTransform()
+        committedTransform.value = withClampedSpring(t)
+        return
+      }
+
+      // Try to zoom in so that we get rid of the black bars (whatever the orientation was).
+      const imageAspect = imageDimensions.width / imageDimensions.height
+      const screenAspect = SCREEN.width / SCREEN.height
+      const candidateScale = Math.max(
+        imageAspect / screenAspect,
+        screenAspect / imageAspect,
+        MIN_DOUBLE_TAP_SCALE,
+      )
+      // But don't zoom in so close that the picture gets blurry.
+      const maxScale =
+        (imageDimensions.width / SCREEN.width) * MAX_ORIGINAL_IMAGE_ZOOM
+      const scale = Math.min(candidateScale, maxScale)
+
+      // Calculate where we would be if the user pinched into the double tapped point.
+      // We won't use this transform directly because it may go out of bounds.
+      const candidateTransform = createTransform()
+      const origin = {
+        x: e.absoluteX - SCREEN.width / 2,
+        y: e.absoluteY - SCREEN.height / 2,
+      }
+      prependPinch(candidateTransform, scale, origin, {x: 0, y: 0})
+
+      // Now we know how much we went out of bounds, so we can shoot correctly.
+      const [dx, dy] = getExtraTranslationToStayInBounds(candidateTransform)
+      const finalTransform = createTransform()
+      prependPinch(finalTransform, scale, origin, {x: dx, y: dy})
+      committedTransform.value = withClampedSpring(finalTransform)
+    })
+
+  const dismissSwipePan = Gesture.Pan()
+    .enabled(!isScaled)
+    .activeOffsetY([-10, 10])
+    .failOffsetX([-10, 10])
+    .maxPointers(1)
+    .onUpdate(e => {
+      dismissSwipeTranslateY.value = e.translationY
+    })
+    .onEnd(e => {
+      if (Math.abs(e.velocityY) > 1000) {
+        dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY})
+        runOnJS(onRequestClose)()
+      } else {
+        dismissSwipeTranslateY.value = withSpring(0, {
+          stiffness: 700,
+          damping: 50,
+        })
+      }
+    })
+
+  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 (
-    <ScrollView
-      ref={imageContainer}
-      style={styles.listItem}
-      pagingEnabled
-      nestedScrollEnabled
-      showsHorizontalScrollIndicator={false}
-      showsVerticalScrollIndicator={false}
-      contentContainerStyle={styles.imageScrollContainer}
-      scrollEnabled={swipeToCloseEnabled}
-      {...(swipeToCloseEnabled && {
-        onScroll,
-        onScrollEndDrag,
-      })}>
-      <AnimatedImage
-        {...panHandlers}
-        source={imageSrc}
-        style={imageStylesWithOpacity}
-        onLoad={onLoaded}
-        accessibilityLabel={imageSrc.alt}
-        accessibilityHint=""
-      />
-      {(!isLoaded || !imageDimensions) && <ImageLoading />}
-    </ScrollView>
+    <Animated.View ref={containerRef} style={styles.container}>
+      {isLoading && (
+        <ActivityIndicator size="small" color="#FFF" style={styles.loading} />
+      )}
+      <GestureDetector gesture={composedGesture}>
+        <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=""
+          onLoad={() => setIsLoaded(true)}
+        />
+      </GestureDetector>
+    </Animated.View>
   )
 }
 
 const styles = StyleSheet.create({
-  listItem: {
-    width: SCREEN_WIDTH,
-    height: SCREEN_HEIGHT,
+  container: {
+    width: SCREEN.width,
+    height: SCREEN.height,
+    overflow: 'hidden',
+  },
+  image: {
+    flex: 1,
   },
-  imageScrollContainer: {
-    height: SCREEN_HEIGHT * 2,
+  loading: {
+    position: 'absolute',
+    left: 0,
+    right: 0,
+    top: 0,
+    bottom: 0,
   },
 })
 
+function getScaledDimensions(
+  imageDimensions: ImageDimensions,
+  scale: number,
+): ImageDimensions {
+  'worklet'
+  const imageAspect = imageDimensions.width / imageDimensions.height
+  const screenAspect = SCREEN.width / SCREEN.height
+  const isLandscape = imageAspect > screenAspect
+  if (isLandscape) {
+    return {
+      width: scale * SCREEN.width,
+      height: (scale * SCREEN.width) / imageAspect,
+    }
+  } else {
+    return {
+      width: scale * SCREEN.height * imageAspect,
+      height: scale * SCREEN.height,
+    }
+  }
+}
+
+function clampTranslation(
+  value: number,
+  scaledSize: number,
+  screenSize: number,
+): number {
+  'worklet'
+  // Figure out how much the user should be allowed to pan, and constrain the translation.
+  const panDistance = Math.max(0, (scaledSize - screenSize) / 2)
+  const clampedValue = Math.min(Math.max(-panDistance, value), panDistance)
+  return clampedValue
+}
+
+function withClampedSpring(value: any) {
+  'worklet'
+  return withSpring(value, {overshootClamping: true})
+}
+
 export default React.memo(ImageItem)
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 03bf45af1..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,159 +6,251 @@
  *
  */
 
-import React, {useCallback, useRef, useState} from 'react'
-
-import {
-  Animated,
-  Dimensions,
-  ScrollView,
-  StyleSheet,
-  View,
-  NativeScrollEvent,
-  NativeSyntheticEvent,
-  TouchableWithoutFeedback,
-} from 'react-native'
+import React, {useState} from 'react'
+
+import {Dimensions, StyleSheet} from 'react-native'
 import {Image} from 'expo-image'
+import Animated, {
+  interpolate,
+  runOnJS,
+  useAnimatedRef,
+  useAnimatedScrollHandler,
+  useAnimatedStyle,
+  useSharedValue,
+} from 'react-native-reanimated'
+import {Gesture, GestureDetector} from 'react-native-gesture-handler'
 
-import useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom'
 import useImageDimensions from '../../hooks/useImageDimensions'
 
-import {getImageStyles, getImageTransform} from '../../utils'
-import {ImageSource} from '../../@types'
+import {ImageSource, Dimensions as ImageDimensions} from '../../@types'
 import {ImageLoading} from './ImageLoading'
 
 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 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
-  onLongPress: (image: ImageSource) => void
-  delayLongPress: number
-  swipeToCloseEnabled?: boolean
-  doubleTapToZoomEnabled?: boolean
+  isScrollViewBeingDragged: boolean
 }
 
 const AnimatedImage = Animated.createAnimatedComponent(Image)
 
-const ImageItem = ({
-  imageSrc,
-  onZoom,
-  onRequestClose,
-  onLongPress,
-  delayLongPress,
-  swipeToCloseEnabled = true,
-  doubleTapToZoomEnabled = true,
-}: 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 handleDoubleTap = useDoubleTapToZoom(
-    scrollViewRef,
-    scaled,
-    SCREEN,
-    imageDimensions,
-  )
-
-  const [translate, scale] = getImageTransform(imageDimensions, SCREEN)
-  const scrollValueY = new Animated.Value(0)
-  const scaleValue = new Animated.Value(scale || 1)
-  const translateValue = new Animated.ValueXY(translate)
-  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,
-    translateValue,
-    scaleValue,
-  )
-  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 &&
-        swipeToCloseEnabled &&
-        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, swipeToCloseEnabled],
-  )
+    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 onLongPressHandler = useCallback(() => {
-    onLongPress(imageSrc)
-  }, [imageSrc, onLongPress])
+  const singleTap = Gesture.Tap().onEnd(() => {
+    runOnJS(onTap)()
+  })
+
+  const doubleTap = Gesture.Tap()
+    .numberOfTaps(2)
+    .onEnd(e => {
+      const {absoluteX, absoluteY} = e
+      runOnJS(handleDoubleTap)(absoluteX, absoluteY)
+    })
+
+  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={swipeToCloseEnabled}
-        onScrollEndDrag={onScrollEndDrag}
-        scrollEventThrottle={1}
-        {...(swipeToCloseEnabled && {
-          onScroll,
-        })}>
+        onScroll={scrollHandler}>
         {(!loaded || !imageDimensions) && <ImageLoading />}
-        <TouchableWithoutFeedback
-          onPress={doubleTapToZoomEnabled ? handleDoubleTap : undefined}
-          onLongPress={onLongPressHandler}
-          delayLongPress={delayLongPress}
-          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,
   },
 })
 
+const getZoomRectAfterDoubleTap = (
+  imageDimensions: ImageDimensions | null,
+  touchX: number,
+  touchY: number,
+): {
+  x: number
+  y: number
+  width: number
+  height: number
+} => {
+  if (!imageDimensions) {
+    return {
+      x: 0,
+      y: 0,
+      width: SCREEN.width,
+      height: SCREEN.height,
+    }
+  }
+
+  // First, let's figure out how much we want to zoom in.
+  // We want to try to zoom in at least close enough to get rid of black bars.
+  const imageAspect = imageDimensions.width / imageDimensions.height
+  const screenAspect = SCREEN.width / SCREEN.height
+  const zoom = Math.max(
+    imageAspect / screenAspect,
+    screenAspect / imageAspect,
+    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.
+
+  // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
+  // We already know the zoom level, so this gives us the rectangle size.
+  let rectWidth = SCREEN.width / zoom
+  let rectHeight = SCREEN.height / zoom
+
+  // Before we settle on the zoomed rect, figure out the safe area it has to be inside.
+  // We don't want to introduce new black bars or make existing black bars unbalanced.
+  let minX = 0
+  let minY = 0
+  let maxX = SCREEN.width - rectWidth
+  let maxY = SCREEN.height - rectHeight
+  if (imageAspect >= screenAspect) {
+    // The image has horizontal black bars. Exclude them from the safe area.
+    const renderedHeight = SCREEN.width / imageAspect
+    const horizontalBarHeight = (SCREEN.height - renderedHeight) / 2
+    minY += horizontalBarHeight
+    maxY -= horizontalBarHeight
+  } else {
+    // The image has vertical black bars. Exclude them from the safe area.
+    const renderedWidth = SCREEN.height * imageAspect
+    const verticalBarWidth = (SCREEN.width - renderedWidth) / 2
+    minX += verticalBarWidth
+    maxX -= verticalBarWidth
+  }
+
+  // Finally, we can position the rect according to its size and the safe area.
+  let rectX
+  if (maxX >= minX) {
+    // Content fills the screen horizontally so we have horizontal wiggle room.
+    // Try to keep the tapped point under the finger after zoom.
+    rectX = touchX - touchX / zoom
+    rectX = Math.min(rectX, maxX)
+    rectX = Math.max(rectX, minX)
+  } else {
+    // Keep the rect centered on the screen so that black bars are balanced.
+    rectX = SCREEN.width / 2 - rectWidth / 2
+  }
+  let rectY
+  if (maxY >= minY) {
+    // Content fills the screen vertically so we have vertical wiggle room.
+    // Try to keep the tapped point under the finger after zoom.
+    rectY = touchY - touchY / zoom
+    rectY = Math.min(rectY, maxY)
+    rectY = Math.max(rectY, minY)
+  } else {
+    // Keep the rect centered on the screen so that black bars are balanced.
+    rectY = SCREEN.height / 2 - rectHeight / 2
+  }
+
+  return {
+    x: rectX,
+    y: rectY,
+    height: rectHeight,
+    width: rectWidth,
+  }
+}
+
 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 fd377dde2..16688b820 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx
@@ -7,11 +7,9 @@ import {ImageSource} from '../../@types'
 type Props = {
   imageSrc: ImageSource
   onRequestClose: () => void
+  onTap: () => void
   onZoom: (scaled: boolean) => void
-  onLongPress: (image: ImageSource) => void
-  delayLongPress: number
-  swipeToCloseEnabled?: boolean
-  doubleTapToZoomEnabled?: boolean
+  isScrollViewBeingDragged: boolean
 }
 
 const ImageItem = (_props: Props) => {
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts b/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts
deleted file mode 100644
index c21cd7f2c..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/useAnimatedComponents.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import {Animated} from 'react-native'
-
-const INITIAL_POSITION = {x: 0, y: 0}
-const ANIMATION_CONFIG = {
-  duration: 200,
-  useNativeDriver: true,
-}
-
-const useAnimatedComponents = () => {
-  const headerTranslate = new Animated.ValueXY(INITIAL_POSITION)
-  const footerTranslate = new Animated.ValueXY(INITIAL_POSITION)
-
-  const toggleVisible = (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 headerTransform = headerTranslate.getTranslateTransform()
-  const footerTransform = footerTranslate.getTranslateTransform()
-
-  return [headerTransform, footerTransform, toggleVisible] as const
-}
-
-export default useAnimatedComponents
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts b/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
deleted file mode 100644
index ea81d9f1c..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/useDoubleTapToZoom.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import React, {useCallback} from 'react'
-import {ScrollView, NativeTouchEvent, NativeSyntheticEvent} from 'react-native'
-
-import {Dimensions} from '../@types'
-
-const DOUBLE_TAP_DELAY = 300
-const MIN_ZOOM = 2
-
-let lastTapTS: number | null = null
-
-/**
- * This is iOS only.
- * Same functionality for Android implemented inside usePanResponder hook.
- */
-function useDoubleTapToZoom(
-  scrollViewRef: React.RefObject<ScrollView>,
-  scaled: boolean,
-  screen: Dimensions,
-  imageDimensions: Dimensions | null,
-) {
-  const handleDoubleTap = useCallback(
-    (event: NativeSyntheticEvent<NativeTouchEvent>) => {
-      const nowTS = new Date().getTime()
-      const scrollResponderRef = scrollViewRef?.current?.getScrollResponder()
-
-      const getZoomRectAfterDoubleTap = (
-        touchX: number,
-        touchY: number,
-      ): {
-        x: number
-        y: number
-        width: number
-        height: number
-      } => {
-        if (!imageDimensions) {
-          return {
-            x: 0,
-            y: 0,
-            width: screen.width,
-            height: screen.height,
-          }
-        }
-
-        // First, let's figure out how much we want to zoom in.
-        // We want to try to zoom in at least close enough to get rid of black bars.
-        const imageAspect = imageDimensions.width / imageDimensions.height
-        const screenAspect = screen.width / screen.height
-        const zoom = Math.max(
-          imageAspect / screenAspect,
-          screenAspect / imageAspect,
-          MIN_ZOOM,
-        )
-        // 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.
-
-        // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates.
-        // We already know the zoom level, so this gives us the rectangle size.
-        let rectWidth = screen.width / zoom
-        let rectHeight = screen.height / zoom
-
-        // Before we settle on the zoomed rect, figure out the safe area it has to be inside.
-        // We don't want to introduce new black bars or make existing black bars unbalanced.
-        let minX = 0
-        let minY = 0
-        let maxX = screen.width - rectWidth
-        let maxY = screen.height - rectHeight
-        if (imageAspect >= screenAspect) {
-          // The image has horizontal black bars. Exclude them from the safe area.
-          const renderedHeight = screen.width / imageAspect
-          const horizontalBarHeight = (screen.height - renderedHeight) / 2
-          minY += horizontalBarHeight
-          maxY -= horizontalBarHeight
-        } else {
-          // The image has vertical black bars. Exclude them from the safe area.
-          const renderedWidth = screen.height * imageAspect
-          const verticalBarWidth = (screen.width - renderedWidth) / 2
-          minX += verticalBarWidth
-          maxX -= verticalBarWidth
-        }
-
-        // Finally, we can position the rect according to its size and the safe area.
-        let rectX
-        if (maxX >= minX) {
-          // Content fills the screen horizontally so we have horizontal wiggle room.
-          // Try to keep the tapped point under the finger after zoom.
-          rectX = touchX - touchX / zoom
-          rectX = Math.min(rectX, maxX)
-          rectX = Math.max(rectX, minX)
-        } else {
-          // Keep the rect centered on the screen so that black bars are balanced.
-          rectX = screen.width / 2 - rectWidth / 2
-        }
-        let rectY
-        if (maxY >= minY) {
-          // Content fills the screen vertically so we have vertical wiggle room.
-          // Try to keep the tapped point under the finger after zoom.
-          rectY = touchY - touchY / zoom
-          rectY = Math.min(rectY, maxY)
-          rectY = Math.max(rectY, minY)
-        } else {
-          // Keep the rect centered on the screen so that black bars are balanced.
-          rectY = screen.height / 2 - rectHeight / 2
-        }
-
-        return {
-          x: rectX,
-          y: rectY,
-          height: rectHeight,
-          width: rectWidth,
-        }
-      }
-
-      if (lastTapTS && nowTS - lastTapTS < DOUBLE_TAP_DELAY) {
-        let nextZoomRect = {
-          x: 0,
-          y: 0,
-          width: screen.width,
-          height: screen.height,
-        }
-
-        const willZoom = !scaled
-        if (willZoom) {
-          const {pageX, pageY} = event.nativeEvent
-          nextZoomRect = getZoomRectAfterDoubleTap(pageX, pageY)
-        }
-
-        // @ts-ignore
-        scrollResponderRef?.scrollResponderZoomTo({
-          ...nextZoomRect, // This rect is in screen coordinates
-          animated: true,
-        })
-      } else {
-        lastTapTS = nowTS
-      }
-    },
-    [imageDimensions, scaled, screen.height, screen.width, scrollViewRef],
-  )
-
-  return handleDoubleTap
-}
-
-export default useDoubleTapToZoom
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts
index a5b0b6bd4..cb46fd0d9 100644
--- a/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts
+++ b/src/view/com/lightbox/ImageViewing/hooks/useImageDimensions.ts
@@ -8,11 +8,29 @@
 
 import {useEffect, useState} from 'react'
 import {Image, ImageURISource} from 'react-native'
-
-import {createCache} from '../utils'
 import {Dimensions, ImageSource} from '../@types'
 
 const CACHE_SIZE = 50
+
+type CacheStorageItem = {key: string; value: any}
+
+const createCache = (cacheSize: number) => ({
+  _storage: [] as CacheStorageItem[],
+  get(key: string): any {
+    const {value} =
+      this._storage.find(({key: storageKey}) => storageKey === key) || {}
+
+    return value
+  },
+  set(key: string, value: any) {
+    if (this._storage.length >= cacheSize) {
+      this._storage.shift()
+    }
+
+    this._storage.push({key, value})
+  },
+})
+
 const imageDimensionsCache = createCache(CACHE_SIZE)
 
 const useImageDimensions = (image: ImageSource): Dimensions | null => {
@@ -21,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/hooks/useImageIndexChange.ts b/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts
deleted file mode 100644
index 16430f3aa..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/useImageIndexChange.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import {useState} from 'react'
-import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
-
-import {Dimensions} from '../@types'
-
-const useImageIndexChange = (imageIndex: number, screen: Dimensions) => {
-  const [currentImageIndex, setImageIndex] = useState(imageIndex)
-  const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
-    const {
-      nativeEvent: {
-        contentOffset: {x: scrollX},
-      },
-    } = event
-
-    if (screen.width) {
-      const nextIndex = Math.round(scrollX / screen.width)
-      setImageIndex(nextIndex < 0 ? 0 : nextIndex)
-    }
-  }
-
-  return [currentImageIndex, onScroll] as const
-}
-
-export default useImageIndexChange
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts b/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts
deleted file mode 100644
index 3969945bb..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/useImagePrefetch.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import {useEffect} from 'react'
-import {Image} from 'react-native'
-import {ImageSource} from '../@types'
-
-const useImagePrefetch = (images: ImageSource[]) => {
-  useEffect(() => {
-    images.forEach(image => {
-      //@ts-ignore
-      if (image.uri) {
-        //@ts-ignore
-        return Image.prefetch(image.uri)
-      }
-    })
-  }, [images])
-}
-
-export default useImagePrefetch
diff --git a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts b/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts
deleted file mode 100644
index 7908504ea..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/usePanResponder.ts
+++ /dev/null
@@ -1,431 +0,0 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import {useEffect} from 'react'
-import {
-  Animated,
-  Dimensions,
-  GestureResponderEvent,
-  GestureResponderHandlers,
-  NativeTouchEvent,
-  PanResponder,
-  PanResponderGestureState,
-} from 'react-native'
-
-import {Position} from '../@types'
-import {
-  getDistanceBetweenTouches,
-  getImageTranslate,
-  getImageDimensionsByTranslate,
-} from '../utils'
-
-const SCREEN = Dimensions.get('window')
-const SCREEN_WIDTH = SCREEN.width
-const SCREEN_HEIGHT = SCREEN.height
-const MIN_DIMENSION = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT)
-const ANDROID_BAR_HEIGHT = 24
-
-const MIN_ZOOM = 2
-const MAX_SCALE = 2
-const DOUBLE_TAP_DELAY = 300
-const OUT_BOUND_MULTIPLIER = 0.75
-
-type Props = {
-  initialScale: number
-  initialTranslate: Position
-  onZoom: (isZoomed: boolean) => void
-  doubleTapToZoomEnabled: boolean
-  onLongPress: () => void
-  delayLongPress: number
-}
-
-const usePanResponder = ({
-  initialScale,
-  initialTranslate,
-  onZoom,
-  doubleTapToZoomEnabled,
-  onLongPress,
-  delayLongPress,
-}: Props): Readonly<
-  [GestureResponderHandlers, Animated.Value, Animated.ValueXY]
-> => {
-  let numberInitialTouches = 1
-  let initialTouches: NativeTouchEvent[] = []
-  let currentScale = initialScale
-  let currentTranslate = initialTranslate
-  let tmpScale = 0
-  let tmpTranslate: Position | null = null
-  let isDoubleTapPerformed = false
-  let lastTapTS: number | null = null
-  let longPressHandlerRef: NodeJS.Timeout | null = null
-
-  const meaningfulShift = MIN_DIMENSION * 0.01
-  const scaleValue = new Animated.Value(initialScale)
-  const translateValue = new Animated.ValueXY(initialTranslate)
-
-  const imageDimensions = getImageDimensionsByTranslate(
-    initialTranslate,
-    SCREEN,
-  )
-
-  const getBounds = (scale: number) => {
-    const scaledImageDimensions = {
-      width: imageDimensions.width * scale,
-      height: imageDimensions.height * scale,
-    }
-    const translateDelta = getImageTranslate(scaledImageDimensions, SCREEN)
-
-    const left = initialTranslate.x - translateDelta.x
-    const right = left - (scaledImageDimensions.width - SCREEN.width)
-    const top = initialTranslate.y - translateDelta.y
-    const bottom = top - (scaledImageDimensions.height - SCREEN.height)
-
-    return [top, left, bottom, right]
-  }
-
-  const getTransformAfterDoubleTap = (
-    touchX: number,
-    touchY: number,
-  ): [number, Position] => {
-    let nextScale = initialScale
-    let nextTranslateX = initialTranslate.x
-    let nextTranslateY = initialTranslate.y
-
-    // First, let's figure out how much we want to zoom in.
-    // We want to try to zoom in at least close enough to get rid of black bars.
-    const imageAspect = imageDimensions.width / imageDimensions.height
-    const screenAspect = SCREEN.width / SCREEN.height
-    let zoom = Math.max(
-      imageAspect / screenAspect,
-      screenAspect / imageAspect,
-      MIN_ZOOM,
-    )
-    // Don't zoom so hard that the original image's pixels become blurry.
-    zoom = Math.min(zoom, MAX_SCALE / initialScale)
-    nextScale = initialScale * zoom
-
-    // Next, let's see if we need to adjust the scaled image translation.
-    // Ideally, we want the tapped point to stay under the finger after the scaling.
-    const dx = SCREEN.width / 2 - touchX
-    const dy = SCREEN.height / 2 - (touchY - ANDROID_BAR_HEIGHT)
-    // Before we try to adjust the translation, check how much wiggle room we have.
-    // We don't want to introduce new black bars or make existing black bars unbalanced.
-    const [topBound, leftBound, bottomBound, rightBound] = getBounds(nextScale)
-    if (leftBound > rightBound) {
-      // Content fills the screen horizontally so we have horizontal wiggle room.
-      // Try to keep the tapped point under the finger after zoom.
-      nextTranslateX += dx * zoom - dx
-      nextTranslateX = Math.min(nextTranslateX, leftBound)
-      nextTranslateX = Math.max(nextTranslateX, rightBound)
-    }
-    if (topBound > bottomBound) {
-      // Content fills the screen vertically so we have vertical wiggle room.
-      // Try to keep the tapped point under the finger after zoom.
-      nextTranslateY += dy * zoom - dy
-      nextTranslateY = Math.min(nextTranslateY, topBound)
-      nextTranslateY = Math.max(nextTranslateY, bottomBound)
-    }
-
-    return [
-      nextScale,
-      {
-        x: nextTranslateX,
-        y: nextTranslateY,
-      },
-    ]
-  }
-
-  const fitsScreenByWidth = () =>
-    imageDimensions.width * currentScale < SCREEN_WIDTH
-  const fitsScreenByHeight = () =>
-    imageDimensions.height * currentScale < SCREEN_HEIGHT
-
-  useEffect(() => {
-    scaleValue.addListener(({value}) => {
-      if (typeof onZoom === 'function') {
-        onZoom(value !== initialScale)
-      }
-    })
-
-    return () => scaleValue.removeAllListeners()
-  })
-
-  const cancelLongPressHandle = () => {
-    longPressHandlerRef && clearTimeout(longPressHandlerRef)
-  }
-
-  const panResponder = PanResponder.create({
-    onStartShouldSetPanResponder: () => true,
-    onStartShouldSetPanResponderCapture: () => true,
-    onMoveShouldSetPanResponder: () => true,
-    onMoveShouldSetPanResponderCapture: () => true,
-    onPanResponderGrant: (
-      _: GestureResponderEvent,
-      gestureState: PanResponderGestureState,
-    ) => {
-      numberInitialTouches = gestureState.numberActiveTouches
-
-      if (gestureState.numberActiveTouches > 1) {
-        return
-      }
-
-      longPressHandlerRef = setTimeout(onLongPress, delayLongPress)
-    },
-    onPanResponderStart: (
-      event: GestureResponderEvent,
-      gestureState: PanResponderGestureState,
-    ) => {
-      initialTouches = event.nativeEvent.touches
-      numberInitialTouches = gestureState.numberActiveTouches
-
-      if (gestureState.numberActiveTouches > 1) {
-        return
-      }
-
-      const tapTS = Date.now()
-      // Handle double tap event by calculating diff between first and second taps timestamps
-
-      isDoubleTapPerformed = Boolean(
-        lastTapTS && tapTS - lastTapTS < DOUBLE_TAP_DELAY,
-      )
-
-      if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
-        let nextScale = initialScale
-        let nextTranslate = initialTranslate
-
-        const willZoom = currentScale === initialScale
-        if (willZoom) {
-          const {pageX: touchX, pageY: touchY} = event.nativeEvent.touches[0]
-          ;[nextScale, nextTranslate] = getTransformAfterDoubleTap(
-            touchX,
-            touchY,
-          )
-        }
-        onZoom(willZoom)
-
-        Animated.parallel(
-          [
-            Animated.timing(translateValue.x, {
-              toValue: nextTranslate.x,
-              duration: 300,
-              useNativeDriver: true,
-            }),
-            Animated.timing(translateValue.y, {
-              toValue: nextTranslate.y,
-              duration: 300,
-              useNativeDriver: true,
-            }),
-            Animated.timing(scaleValue, {
-              toValue: nextScale,
-              duration: 300,
-              useNativeDriver: true,
-            }),
-          ],
-          {stopTogether: false},
-        ).start(() => {
-          currentScale = nextScale
-          currentTranslate = nextTranslate
-        })
-
-        lastTapTS = null
-      } else {
-        lastTapTS = Date.now()
-      }
-    },
-    onPanResponderMove: (
-      event: GestureResponderEvent,
-      gestureState: PanResponderGestureState,
-    ) => {
-      const {dx, dy} = gestureState
-
-      if (Math.abs(dx) >= meaningfulShift || Math.abs(dy) >= meaningfulShift) {
-        cancelLongPressHandle()
-      }
-
-      // Don't need to handle move because double tap in progress (was handled in onStart)
-      if (doubleTapToZoomEnabled && isDoubleTapPerformed) {
-        cancelLongPressHandle()
-        return
-      }
-
-      if (
-        numberInitialTouches === 1 &&
-        gestureState.numberActiveTouches === 2
-      ) {
-        numberInitialTouches = 2
-        initialTouches = event.nativeEvent.touches
-      }
-
-      const isTapGesture =
-        numberInitialTouches === 1 && gestureState.numberActiveTouches === 1
-      const isPinchGesture =
-        numberInitialTouches === 2 && gestureState.numberActiveTouches === 2
-
-      if (isPinchGesture) {
-        cancelLongPressHandle()
-
-        const initialDistance = getDistanceBetweenTouches(initialTouches)
-        const currentDistance = getDistanceBetweenTouches(
-          event.nativeEvent.touches,
-        )
-
-        let nextScale = (currentDistance / initialDistance) * currentScale
-
-        /**
-         * In case image is scaling smaller than initial size ->
-         * slow down this transition by applying OUT_BOUND_MULTIPLIER
-         */
-        if (nextScale < initialScale) {
-          nextScale =
-            nextScale + (initialScale - nextScale) * OUT_BOUND_MULTIPLIER
-        }
-
-        /**
-         * In case image is scaling down -> move it in direction of initial position
-         */
-        if (currentScale > initialScale && currentScale > nextScale) {
-          const k = (currentScale - initialScale) / (currentScale - nextScale)
-
-          const nextTranslateX =
-            nextScale < initialScale
-              ? initialTranslate.x
-              : currentTranslate.x -
-                (currentTranslate.x - initialTranslate.x) / k
-
-          const nextTranslateY =
-            nextScale < initialScale
-              ? initialTranslate.y
-              : currentTranslate.y -
-                (currentTranslate.y - initialTranslate.y) / k
-
-          translateValue.x.setValue(nextTranslateX)
-          translateValue.y.setValue(nextTranslateY)
-
-          tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
-        }
-
-        scaleValue.setValue(nextScale)
-        tmpScale = nextScale
-      }
-
-      if (isTapGesture && currentScale > initialScale) {
-        const {x, y} = currentTranslate
-        // eslint-disable-next-line @typescript-eslint/no-shadow
-        const {dx, dy} = gestureState
-        const [topBound, leftBound, bottomBound, rightBound] =
-          getBounds(currentScale)
-
-        let nextTranslateX = x + dx
-        let nextTranslateY = y + dy
-
-        if (nextTranslateX > leftBound) {
-          nextTranslateX =
-            nextTranslateX - (nextTranslateX - leftBound) * OUT_BOUND_MULTIPLIER
-        }
-
-        if (nextTranslateX < rightBound) {
-          nextTranslateX =
-            nextTranslateX -
-            (nextTranslateX - rightBound) * OUT_BOUND_MULTIPLIER
-        }
-
-        if (nextTranslateY > topBound) {
-          nextTranslateY =
-            nextTranslateY - (nextTranslateY - topBound) * OUT_BOUND_MULTIPLIER
-        }
-
-        if (nextTranslateY < bottomBound) {
-          nextTranslateY =
-            nextTranslateY -
-            (nextTranslateY - bottomBound) * OUT_BOUND_MULTIPLIER
-        }
-
-        if (fitsScreenByWidth()) {
-          nextTranslateX = x
-        }
-
-        if (fitsScreenByHeight()) {
-          nextTranslateY = y
-        }
-
-        translateValue.x.setValue(nextTranslateX)
-        translateValue.y.setValue(nextTranslateY)
-
-        tmpTranslate = {x: nextTranslateX, y: nextTranslateY}
-      }
-    },
-    onPanResponderRelease: () => {
-      cancelLongPressHandle()
-
-      if (isDoubleTapPerformed) {
-        isDoubleTapPerformed = false
-      }
-
-      if (tmpScale > 0) {
-        if (tmpScale < initialScale || tmpScale > MAX_SCALE) {
-          tmpScale = tmpScale < initialScale ? initialScale : MAX_SCALE
-          Animated.timing(scaleValue, {
-            toValue: tmpScale,
-            duration: 100,
-            useNativeDriver: true,
-          }).start()
-        }
-
-        currentScale = tmpScale
-        tmpScale = 0
-      }
-
-      if (tmpTranslate) {
-        const {x, y} = tmpTranslate
-        const [topBound, leftBound, bottomBound, rightBound] =
-          getBounds(currentScale)
-
-        let nextTranslateX = x
-        let nextTranslateY = y
-
-        if (!fitsScreenByWidth()) {
-          if (nextTranslateX > leftBound) {
-            nextTranslateX = leftBound
-          } else if (nextTranslateX < rightBound) {
-            nextTranslateX = rightBound
-          }
-        }
-
-        if (!fitsScreenByHeight()) {
-          if (nextTranslateY > topBound) {
-            nextTranslateY = topBound
-          } else if (nextTranslateY < bottomBound) {
-            nextTranslateY = bottomBound
-          }
-        }
-
-        Animated.parallel([
-          Animated.timing(translateValue.x, {
-            toValue: nextTranslateX,
-            duration: 100,
-            useNativeDriver: true,
-          }),
-          Animated.timing(translateValue.y, {
-            toValue: nextTranslateY,
-            duration: 100,
-            useNativeDriver: true,
-          }),
-        ]).start()
-
-        currentTranslate = {x: nextTranslateX, y: nextTranslateY}
-        tmpTranslate = null
-      }
-    },
-    onPanResponderTerminationRequest: () => false,
-    onShouldBlockNativeResponder: () => false,
-  })
-
-  return [panResponder.panHandlers, scaleValue, translateValue]
-}
-
-export default usePanResponder
diff --git a/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts b/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts
deleted file mode 100644
index 4cd03fe71..000000000
--- a/src/view/com/lightbox/ImageViewing/hooks/useRequestClose.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import {useState} from 'react'
-
-const useRequestClose = (onRequestClose: () => void) => {
-  const [opacity, setOpacity] = useState(1)
-
-  return [
-    opacity,
-    () => {
-      setOpacity(0)
-      onRequestClose()
-      setTimeout(() => setOpacity(1), 0)
-    },
-  ] as const
-}
-
-export default useRequestClose
diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx
index 1a64fb3af..b6835793d 100644
--- a/src/view/com/lightbox/ImageViewing/index.tsx
+++ b/src/view/com/lightbox/ImageViewing/index.tsx
@@ -8,91 +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,
-  useCallback,
-  useRef,
-  useEffect,
-  useMemo,
-} from 'react'
-import {
-  Animated,
-  Dimensions,
-  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 useAnimatedComponents from './hooks/useAnimatedComponents'
-import useImageIndexChange from './hooks/useImageIndexChange'
-import useRequestClose from './hooks/useRequestClose'
 import {ImageSource} from './@types'
+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
-  onLongPress?: (image: ImageSource) => void
-  onImageIndexChange?: (imageIndex: number) => void
-  presentationStyle?: ModalProps['presentationStyle']
-  animationType?: ModalProps['animationType']
   backgroundColor?: string
-  swipeToCloseEnabled?: boolean
-  doubleTapToZoomEnabled?: boolean
-  delayLongPress?: number
   HeaderComponent?: ComponentType<{imageIndex: number}>
   FooterComponent?: ComponentType<{imageIndex: number}>
 }
 
 const DEFAULT_BG_COLOR = '#000'
-const DEFAULT_DELAY_LONG_PRESS = 800
-const SCREEN = Dimensions.get('screen')
-const SCREEN_WIDTH = SCREEN.width
 
 function ImageViewing({
   images,
-  keyExtractor,
-  imageIndex,
+  initialImageIndex,
   visible,
   onRequestClose,
-  onLongPress = () => {},
-  onImageIndexChange,
   backgroundColor = DEFAULT_BG_COLOR,
-  swipeToCloseEnabled,
-  doubleTapToZoomEnabled,
-  delayLongPress = DEFAULT_DELAY_LONG_PRESS,
   HeaderComponent,
   FooterComponent,
 }: Props) {
-  const imageList = useRef<VirtualizedList<ImageSource>>(null)
-  const [opacity, onRequestCloseEnhanced] = useRequestClose(onRequestClose)
-  const [currentImageIndex, onScroll] = useImageIndexChange(imageIndex, SCREEN)
-  const [headerTransform, footerTransform, toggleBarsVisible] =
-    useAnimatedComponents()
-
-  useEffect(() => {
-    if (onImageIndexChange) {
-      onImageIndexChange(currentImageIndex)
+  const [isScaled, setIsScaled] = useState(false)
+  const [isDragging, setIsDragging] = useState(false)
+  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),
+      },
+    ],
+  }))
+
+  const onTap = useCallback(() => {
+    setShowControls(show => !show)
+  }, [])
+
+  const onZoom = useCallback((nextIsScaled: boolean) => {
+    setIsScaled(nextIsScaled)
+    if (nextIsScaled) {
+      setShowControls(false)
     }
-  }, [currentImageIndex, onImageIndexChange])
-
-  const onZoom = useCallback(
-    (isScaled: boolean) => {
-      // @ts-ignore
-      imageList?.current?.setNativeProps({scrollEnabled: !isScaled})
-      toggleBarsVisible(!isScaled)
-    },
-    [toggleBarsVisible],
-  )
+  }, [])
 
   const edges = useMemo(() => {
     if (Platform.OS === 'android') {
@@ -101,12 +82,6 @@ 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])
-
   if (!visible) {
     return null
   }
@@ -114,60 +89,47 @@ function ImageViewing({
   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
-          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}
-              onLongPress={onLongPress}
-              delayLongPress={delayLongPress}
-              swipeToCloseEnabled={swipeToCloseEnabled}
-              doubleTapToZoomEnabled={doubleTapToZoomEnabled}
-            />
-          )}
-          onMomentumScrollEnd={onScroll}
-          //@ts-ignore
-          keyExtractor={(imageSrc, index) =>
-            keyExtractor
-              ? keyExtractor(imageSrc, index)
-              : typeof imageSrc === 'number'
-              ? `${imageSrc}`
-              : imageSrc.uri
-          }
-        />
+        <PagerView
+          scrollEnabled={!isScaled}
+          initialPage={initialImageIndex}
+          onPageSelected={e => {
+            setImageIndex(e.nativeEvent.position)
+            setIsScaled(false)
+          }}
+          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>
         )}
@@ -179,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%',
@@ -200,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/ImageViewing/transforms.ts b/src/view/com/lightbox/ImageViewing/transforms.ts
new file mode 100644
index 000000000..05476678f
--- /dev/null
+++ b/src/view/com/lightbox/ImageViewing/transforms.ts
@@ -0,0 +1,98 @@
+import type {Position} from './@types'
+
+export type TransformMatrix = [
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+  number,
+]
+
+// These are affine transforms. See explanation of every cell here:
+// https://en.wikipedia.org/wiki/Transformation_matrix#/media/File:2D_affine_transformation_matrix.svg
+
+export function createTransform(): TransformMatrix {
+  'worklet'
+  return [1, 0, 0, 0, 1, 0, 0, 0, 1]
+}
+
+export function applyRounding(t: TransformMatrix) {
+  'worklet'
+  t[2] = Math.round(t[2])
+  t[5] = Math.round(t[5])
+  // For example: 0.985, 0.99, 0.995, then 1:
+  t[0] = Math.round(t[0] * 200) / 200
+  t[4] = Math.round(t[0] * 200) / 200
+}
+
+// We're using a limited subset (always scaling and translating while keeping aspect ratio) so
+// we can assume the transform doesn't encode have skew, rotation, or non-uniform stretching.
+
+// All write operations are applied in-place to avoid unnecessary allocations.
+
+export function readTransform(t: TransformMatrix): [number, number, number] {
+  'worklet'
+  const scale = t[0]
+  const translateX = t[2]
+  const translateY = t[5]
+  return [translateX, translateY, scale]
+}
+
+export function prependTranslate(t: TransformMatrix, x: number, y: number) {
+  'worklet'
+  t[2] += t[0] * x + t[1] * y
+  t[5] += t[3] * x + t[4] * y
+}
+
+export function prependScale(t: TransformMatrix, value: number) {
+  'worklet'
+  t[0] *= value
+  t[1] *= value
+  t[3] *= value
+  t[4] *= value
+}
+
+export function prependTransform(ta: TransformMatrix, tb: TransformMatrix) {
+  'worklet'
+  // In-place matrix multiplication.
+  const a00 = ta[0],
+    a01 = ta[1],
+    a02 = ta[2]
+  const a10 = ta[3],
+    a11 = ta[4],
+    a12 = ta[5]
+  const a20 = ta[6],
+    a21 = ta[7],
+    a22 = ta[8]
+  ta[0] = a00 * tb[0] + a01 * tb[3] + a02 * tb[6]
+  ta[1] = a00 * tb[1] + a01 * tb[4] + a02 * tb[7]
+  ta[2] = a00 * tb[2] + a01 * tb[5] + a02 * tb[8]
+  ta[3] = a10 * tb[0] + a11 * tb[3] + a12 * tb[6]
+  ta[4] = a10 * tb[1] + a11 * tb[4] + a12 * tb[7]
+  ta[5] = a10 * tb[2] + a11 * tb[5] + a12 * tb[8]
+  ta[6] = a20 * tb[0] + a21 * tb[3] + a22 * tb[6]
+  ta[7] = a20 * tb[1] + a21 * tb[4] + a22 * tb[7]
+  ta[8] = a20 * tb[2] + a21 * tb[5] + a22 * tb[8]
+}
+
+export function prependPan(t: TransformMatrix, translation: Position) {
+  'worklet'
+  prependTranslate(t, translation.x, translation.y)
+}
+
+export function prependPinch(
+  t: TransformMatrix,
+  scale: number,
+  origin: Position,
+  translation: Position,
+) {
+  'worklet'
+  prependTranslate(t, translation.x, translation.y)
+  prependTranslate(t, origin.x, origin.y)
+  prependScale(t, scale)
+  prependTranslate(t, -origin.x, -origin.y)
+}
diff --git a/src/view/com/lightbox/ImageViewing/utils.ts b/src/view/com/lightbox/ImageViewing/utils.ts
deleted file mode 100644
index d56eea4f4..000000000
--- a/src/view/com/lightbox/ImageViewing/utils.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-/**
- * Copyright (c) JOB TODAY S.A. and its affiliates.
- *
- * This source code is licensed under the MIT license found in the
- * LICENSE file in the root directory of this source tree.
- *
- */
-
-import {Animated, NativeTouchEvent} from 'react-native'
-import {Dimensions, Position} from './@types'
-
-type CacheStorageItem = {key: string; value: any}
-
-export const createCache = (cacheSize: number) => ({
-  _storage: [] as CacheStorageItem[],
-  get(key: string): any {
-    const {value} =
-      this._storage.find(({key: storageKey}) => storageKey === key) || {}
-
-    return value
-  },
-  set(key: string, value: any) {
-    if (this._storage.length >= cacheSize) {
-      this._storage.shift()
-    }
-
-    this._storage.push({key, value})
-  },
-})
-
-export const splitArrayIntoBatches = (arr: any[], batchSize: number): any[] =>
-  arr.reduce((result, item) => {
-    const batch = result.pop() || []
-
-    if (batch.length < batchSize) {
-      batch.push(item)
-      result.push(batch)
-    } else {
-      result.push(batch, [item])
-    }
-
-    return result
-  }, [])
-
-export const getImageTransform = (
-  image: Dimensions | null,
-  screen: Dimensions,
-) => {
-  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
-}
-
-export const getImageStyles = (
-  image: Dimensions | null,
-  translate: Animated.ValueXY,
-  scale?: Animated.Value,
-) => {
-  if (!image?.width || !image?.height) {
-    return {width: 0, height: 0}
-  }
-
-  const transform = translate.getTranslateTransform()
-
-  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,
-  }
-}
-
-export const getImageTranslate = (
-  image: Dimensions,
-  screen: Dimensions,
-): Position => {
-  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 const getImageDimensionsByTranslate = (
-  translate: Position,
-  screen: Dimensions,
-): Dimensions => ({
-  width: screen.width - translate.x * 2,
-  height: screen.height - translate.y * 2,
-})
-
-export const getImageTranslateForScale = (
-  currentTranslate: Position,
-  targetScale: number,
-  screen: Dimensions,
-): Position => {
-  const {width, height} = getImageDimensionsByTranslate(
-    currentTranslate,
-    screen,
-  )
-
-  const targetImageDimensions = {
-    width: width * targetScale,
-    height: height * targetScale,
-  }
-
-  return getImageTranslate(targetImageDimensions, screen)
-}
-
-export const getDistanceBetweenTouches = (
-  touches: NativeTouchEvent[],
-): number => {
-  const [a, b] = touches
-
-  if (a == null || b == null) {
-    return 0
-  }
-
-  return Math.sqrt(
-    Math.pow(a.pageX - b.pageX, 2) + Math.pow(a.pageY - b.pageY, 2),
-  )
-}
diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx
index 072bfebfa..92c30f491 100644
--- a/src/view/com/lightbox/Lightbox.tsx
+++ b/src/view/com/lightbox/Lightbox.tsx
@@ -15,13 +15,48 @@ import * as MediaLibrary from 'expo-media-library'
 
 export const Lightbox = observer(function Lightbox() {
   const store = useStores()
-  const [isAltExpanded, setAltExpanded] = React.useState(false)
-  const [permissionResponse, requestPermission] = MediaLibrary.usePermissions()
-
   const onClose = React.useCallback(() => {
     store.shell.closeLightbox()
   }, [store])
 
+  if (!store.shell.activeLightbox) {
+    return null
+  } else if (store.shell.activeLightbox.name === 'profile-image') {
+    const opts = store.shell.activeLightbox as models.ProfileImageLightbox
+    return (
+      <ImageView
+        images={[{uri: opts.profileView.avatar || ''}]}
+        initialImageIndex={0}
+        visible
+        onRequestClose={onClose}
+        FooterComponent={LightboxFooter}
+      />
+    )
+  } else if (store.shell.activeLightbox.name === 'images') {
+    const opts = store.shell.activeLightbox as models.ImagesLightbox
+    return (
+      <ImageView
+        images={opts.images.map(img => ({...img}))}
+        initialImageIndex={opts.index}
+        visible
+        onRequestClose={onClose}
+        FooterComponent={LightboxFooter}
+      />
+    )
+  } else {
+    return null
+  }
+})
+
+const LightboxFooter = observer(function LightboxFooter({
+  imageIndex,
+}: {
+  imageIndex: number
+}) {
+  const store = useStores()
+  const [isAltExpanded, setAltExpanded] = React.useState(false)
+  const [permissionResponse, requestPermission] = MediaLibrary.usePermissions()
+
   const saveImageToAlbumWithToasts = React.useCallback(
     async (uri: string) => {
       if (!permissionResponse || permissionResponse.granted === false) {
@@ -46,90 +81,57 @@ export const Lightbox = observer(function Lightbox() {
     [permissionResponse, requestPermission],
   )
 
-  const LightboxFooter = React.useCallback(
-    ({imageIndex}: {imageIndex: number}) => {
-      const lightbox = store.shell.activeLightbox
-      if (!lightbox) {
-        return null
-      }
+  const lightbox = store.shell.activeLightbox
+  if (!lightbox) {
+    return null
+  }
 
-      let altText = ''
-      let uri = ''
-      if (lightbox.name === 'images') {
-        const opts = lightbox as models.ImagesLightbox
-        uri = opts.images[imageIndex].uri
-        altText = opts.images[imageIndex].alt || ''
-      } else if (lightbox.name === 'profile-image') {
-        const opts = lightbox as models.ProfileImageLightbox
-        uri = opts.profileView.avatar || ''
-      }
+  let altText = ''
+  let uri = ''
+  if (lightbox.name === 'images') {
+    const opts = lightbox as models.ImagesLightbox
+    uri = opts.images[imageIndex].uri
+    altText = opts.images[imageIndex].alt || ''
+  } else if (lightbox.name === 'profile-image') {
+    const opts = lightbox as models.ProfileImageLightbox
+    uri = opts.profileView.avatar || ''
+  }
 
-      return (
-        <View style={[styles.footer]}>
-          {altText ? (
-            <Pressable
-              onPress={() => setAltExpanded(!isAltExpanded)}
-              accessibilityRole="button">
-              <Text
-                style={[s.gray3, styles.footerText]}
-                numberOfLines={isAltExpanded ? undefined : 3}>
-                {altText}
-              </Text>
-            </Pressable>
-          ) : null}
-          <View style={styles.footerBtns}>
-            <Button
-              type="primary-outline"
-              style={styles.footerBtn}
-              onPress={() => saveImageToAlbumWithToasts(uri)}>
-              <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
-              <Text type="xl" style={s.white}>
-                Save
-              </Text>
-            </Button>
-            <Button
-              type="primary-outline"
-              style={styles.footerBtn}
-              onPress={() => shareImageModal({uri})}>
-              <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
-              <Text type="xl" style={s.white}>
-                Share
-              </Text>
-            </Button>
-          </View>
-        </View>
-      )
-    },
-    [store.shell.activeLightbox, isAltExpanded, saveImageToAlbumWithToasts],
+  return (
+    <View style={[styles.footer]}>
+      {altText ? (
+        <Pressable
+          onPress={() => setAltExpanded(!isAltExpanded)}
+          accessibilityRole="button">
+          <Text
+            style={[s.gray3, styles.footerText]}
+            numberOfLines={isAltExpanded ? undefined : 3}>
+            {altText}
+          </Text>
+        </Pressable>
+      ) : null}
+      <View style={styles.footerBtns}>
+        <Button
+          type="primary-outline"
+          style={styles.footerBtn}
+          onPress={() => saveImageToAlbumWithToasts(uri)}>
+          <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
+          <Text type="xl" style={s.white}>
+            Save
+          </Text>
+        </Button>
+        <Button
+          type="primary-outline"
+          style={styles.footerBtn}
+          onPress={() => shareImageModal({uri})}>
+          <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
+          <Text type="xl" style={s.white}>
+            Share
+          </Text>
+        </Button>
+      </View>
+    </View>
   )
-
-  if (!store.shell.activeLightbox) {
-    return null
-  } else if (store.shell.activeLightbox.name === 'profile-image') {
-    const opts = store.shell.activeLightbox as models.ProfileImageLightbox
-    return (
-      <ImageView
-        images={[{uri: opts.profileView.avatar || ''}]}
-        imageIndex={0}
-        visible
-        onRequestClose={onClose}
-        FooterComponent={LightboxFooter}
-      />
-    )
-  } else if (store.shell.activeLightbox.name === 'images') {
-    const opts = store.shell.activeLightbox as models.ImagesLightbox
-    return (
-      <ImageView
-        images={opts.images.map(img => ({...img}))}
-        imageIndex={opts.index}
-        visible
-        onRequestClose={onClose}
-        FooterComponent={LightboxFooter}
-      />
-    )
-  } else {
-    return null
-  }
 })
 
 const styles = StyleSheet.create({
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/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/SwitchAccount.tsx b/src/view/com/modals/SwitchAccount.tsx
index 51d75e3ef..d5fa32692 100644
--- a/src/view/com/modals/SwitchAccount.tsx
+++ b/src/view/com/modals/SwitchAccount.tsx
@@ -37,74 +37,69 @@ export function Component({}: {}) {
   }, [track, store])
 
   return (
-    <View style={[styles.container, pal.view]}>
+    <BottomSheetScrollView
+      style={[styles.container, pal.view]}
+      contentContainerStyle={[styles.innerContainer, pal.view]}>
       <Text type="title-xl" style={[styles.title, pal.text]}>
         Switch Account
       </Text>
-      <BottomSheetScrollView
-        style={styles.container}
-        contentContainerStyle={[styles.innerContainer, pal.view]}>
-        {isSwitching ? (
+      {isSwitching ? (
+        <View style={[pal.view, styles.linkCard]}>
+          <ActivityIndicator />
+        </View>
+      ) : (
+        <Link href={makeProfileLink(store.me)} title="Your profile" noFeedback>
           <View style={[pal.view, styles.linkCard]}>
-            <ActivityIndicator />
-          </View>
-        ) : (
-          <Link
-            href={makeProfileLink(store.me)}
-            title="Your profile"
-            noFeedback>
-            <View style={[pal.view, styles.linkCard]}>
-              <View style={styles.avi}>
-                <UserAvatar size={40} avatar={store.me.avatar} />
-              </View>
-              <View style={[s.flex1]}>
-                <Text type="md-bold" style={pal.text} numberOfLines={1}>
-                  {store.me.displayName || store.me.handle}
-                </Text>
-                <Text type="sm" style={pal.textLight} numberOfLines={1}>
-                  {store.me.handle}
-                </Text>
-              </View>
-              <TouchableOpacity
-                testID="signOutBtn"
-                onPress={isSwitching ? undefined : onPressSignout}
-                accessibilityRole="button"
-                accessibilityLabel="Sign out"
-                accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}>
-                <Text type="lg" style={pal.link}>
-                  Sign out
-                </Text>
-              </TouchableOpacity>
-            </View>
-          </Link>
-        )}
-        {store.session.switchableAccounts.map(account => (
-          <TouchableOpacity
-            testID={`switchToAccountBtn-${account.handle}`}
-            key={account.did}
-            style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]}
-            onPress={
-              isSwitching ? undefined : () => onPressSwitchAccount(account)
-            }
-            accessibilityRole="button"
-            accessibilityLabel={`Switch to ${account.handle}`}
-            accessibilityHint="Switches the account you are logged in to">
             <View style={styles.avi}>
-              <UserAvatar size={40} avatar={account.aviUrl} />
+              <UserAvatar size={40} avatar={store.me.avatar} />
             </View>
             <View style={[s.flex1]}>
-              <Text type="md-bold" style={pal.text}>
-                {account.displayName || account.handle}
+              <Text type="md-bold" style={pal.text} numberOfLines={1}>
+                {store.me.displayName || store.me.handle}
               </Text>
-              <Text type="sm" style={pal.textLight}>
-                {account.handle}
+              <Text type="sm" style={pal.textLight} numberOfLines={1}>
+                {store.me.handle}
               </Text>
             </View>
-            <AccountDropdownBtn handle={account.handle} />
-          </TouchableOpacity>
-        ))}
-      </BottomSheetScrollView>
-    </View>
+            <TouchableOpacity
+              testID="signOutBtn"
+              onPress={isSwitching ? undefined : onPressSignout}
+              accessibilityRole="button"
+              accessibilityLabel="Sign out"
+              accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}>
+              <Text type="lg" style={pal.link}>
+                Sign out
+              </Text>
+            </TouchableOpacity>
+          </View>
+        </Link>
+      )}
+      {store.session.switchableAccounts.map(account => (
+        <TouchableOpacity
+          testID={`switchToAccountBtn-${account.handle}`}
+          key={account.did}
+          style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]}
+          onPress={
+            isSwitching ? undefined : () => onPressSwitchAccount(account)
+          }
+          accessibilityRole="button"
+          accessibilityLabel={`Switch to ${account.handle}`}
+          accessibilityHint="Switches the account you are logged in to">
+          <View style={styles.avi}>
+            <UserAvatar size={40} avatar={account.aviUrl} />
+          </View>
+          <View style={[s.flex1]}>
+            <Text type="md-bold" style={pal.text}>
+              {account.displayName || account.handle}
+            </Text>
+            <Text type="sm" style={pal.textLight}>
+              {account.handle}
+            </Text>
+          </View>
+          <AccountDropdownBtn handle={account.handle} />
+        </TouchableOpacity>
+      ))}
+    </BottomSheetScrollView>
   )
 }
 
@@ -113,7 +108,7 @@ const styles = StyleSheet.create({
     flex: 1,
   },
   innerContainer: {
-    paddingBottom: 20,
+    paddingBottom: 40,
   },
   title: {
     textAlign: 'center',
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/notifications/InvitedUsers.tsx b/src/view/com/notifications/InvitedUsers.tsx
index 89a0da47f..aaf358b87 100644
--- a/src/view/com/notifications/InvitedUsers.tsx
+++ b/src/view/com/notifications/InvitedUsers.tsx
@@ -75,7 +75,7 @@ function InvitedUser({
           <FollowButton
             unfollowedType="primary"
             followedType="primary-light"
-            did={profile.did}
+            profile={profile}
           />
           <Button
             testID="dismissBtn"
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/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index c53c2686c..378ef5028 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -439,5 +439,7 @@ const styles = StyleSheet.create({
   parentSpinner: {
     paddingVertical: 10,
   },
-  childSpinner: {},
+  childSpinner: {
+    paddingBottom: 200,
+  },
 })
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 55e69a318..74883f82a 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -33,6 +33,7 @@ export const Feed = observer(function Feed({
   onScroll,
   scrollEventThrottle,
   renderEmptyState,
+  renderEndOfFeed,
   testID,
   headerOffset = 0,
   ListHeaderComponent,
@@ -44,7 +45,8 @@ 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
   ListHeaderComponent?: () => JSX.Element
@@ -94,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 {
@@ -114,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
@@ -142,14 +141,16 @@ export const Feed = observer(function Feed({
 
   const FeedFooter = React.useCallback(
     () =>
-      feed.isLoading ? (
+      feed.isLoadingMore ? (
         <View style={styles.feedFooter}>
           <ActivityIndicator />
         </View>
+      ) : !feed.hasMore && !feed.isEmpty && renderEndOfFeed ? (
+        renderEndOfFeed()
       ) : (
         <View />
       ),
-    [feed],
+    [feed.isLoadingMore, feed.hasMore, feed.isEmpty, renderEndOfFeed],
   )
 
   return (
@@ -177,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/posts/FollowingEmptyState.tsx b/src/view/com/posts/FollowingEmptyState.tsx
index a73ffb68b..61a27e48e 100644
--- a/src/view/com/posts/FollowingEmptyState.tsx
+++ b/src/view/com/posts/FollowingEmptyState.tsx
@@ -28,60 +28,73 @@ export function FollowingEmptyState() {
   }, [navigation])
 
   const onPressDiscoverFeeds = React.useCallback(() => {
-    navigation.navigate('Feeds')
+    if (isWeb) {
+      navigation.navigate('Feeds')
+    } else {
+      navigation.navigate('FeedsTab')
+      navigation.popToTop()
+    }
   }, [navigation])
 
   return (
-    <View style={styles.emptyContainer}>
-      <View style={styles.emptyIconContainer}>
-        <MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} />
-      </View>
-      <Text type="xl-medium" style={[s.textCenter, pal.text]}>
-        Your following feed is empty! Find some accounts to follow to fix this.
-      </Text>
-      <Button
-        type="inverted"
-        style={styles.emptyBtn}
-        onPress={onPressFindAccounts}>
-        <Text type="lg-medium" style={palInverted.text}>
-          Find accounts to follow
+    <View style={styles.container}>
+      <View style={styles.inner}>
+        <View style={styles.iconContainer}>
+          <MagnifyingGlassIcon style={[styles.icon, pal.text]} size={62} />
+        </View>
+        <Text type="xl-medium" style={[s.textCenter, pal.text]}>
+          Your following feed is empty! Follow more users to see what's
+          happening.
         </Text>
-        <FontAwesomeIcon
-          icon="angle-right"
-          style={palInverted.text as FontAwesomeIconStyle}
-          size={14}
-        />
-      </Button>
+        <Button
+          type="inverted"
+          style={styles.emptyBtn}
+          onPress={onPressFindAccounts}>
+          <Text type="lg-medium" style={palInverted.text}>
+            Find accounts to follow
+          </Text>
+          <FontAwesomeIcon
+            icon="angle-right"
+            style={palInverted.text as FontAwesomeIconStyle}
+            size={14}
+          />
+        </Button>
 
-      <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}>
-        You can also discover new Custom Feeds to follow.
-      </Text>
-      <Button
-        type="inverted"
-        style={[styles.emptyBtn, s.mt10]}
-        onPress={onPressDiscoverFeeds}>
-        <Text type="lg-medium" style={palInverted.text}>
-          Discover new custom feeds
+        <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}>
+          You can also discover new Custom Feeds to follow.
         </Text>
-        <FontAwesomeIcon
-          icon="angle-right"
-          style={palInverted.text as FontAwesomeIconStyle}
-          size={14}
-        />
-      </Button>
+        <Button
+          type="inverted"
+          style={[styles.emptyBtn, s.mt10]}
+          onPress={onPressDiscoverFeeds}>
+          <Text type="lg-medium" style={palInverted.text}>
+            Discover new custom feeds
+          </Text>
+          <FontAwesomeIcon
+            icon="angle-right"
+            style={palInverted.text as FontAwesomeIconStyle}
+            size={14}
+          />
+        </Button>
+      </View>
     </View>
   )
 }
 const styles = StyleSheet.create({
-  emptyContainer: {
+  container: {
     height: '100%',
+    flexDirection: 'row',
+    justifyContent: 'center',
     paddingVertical: 40,
     paddingHorizontal: 30,
   },
-  emptyIconContainer: {
+  inner: {
+    maxWidth: 460,
+  },
+  iconContainer: {
     marginBottom: 16,
   },
-  emptyIcon: {
+  icon: {
     marginLeft: 'auto',
     marginRight: 'auto',
   },
@@ -94,13 +107,4 @@ const styles = StyleSheet.create({
     paddingHorizontal: 24,
     borderRadius: 30,
   },
-
-  feedsTip: {
-    position: 'absolute',
-    left: 22,
-  },
-  feedsTipArrow: {
-    marginLeft: 32,
-    marginTop: 8,
-  },
 })
diff --git a/src/view/com/posts/FollowingEndOfFeed.tsx b/src/view/com/posts/FollowingEndOfFeed.tsx
new file mode 100644
index 000000000..48724d8b3
--- /dev/null
+++ b/src/view/com/posts/FollowingEndOfFeed.tsx
@@ -0,0 +1,100 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {useNavigation} from '@react-navigation/native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {Text} from '../util/text/Text'
+import {Button} from '../util/forms/Button'
+import {NavigationProp} from 'lib/routes/types'
+import {usePalette} from 'lib/hooks/usePalette'
+import {s} from 'lib/styles'
+import {isWeb} from 'platform/detection'
+
+export function FollowingEndOfFeed() {
+  const pal = usePalette('default')
+  const palInverted = usePalette('inverted')
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPressFindAccounts = React.useCallback(() => {
+    if (isWeb) {
+      navigation.navigate('Search', {})
+    } else {
+      navigation.navigate('SearchTab')
+      navigation.popToTop()
+    }
+  }, [navigation])
+
+  const onPressDiscoverFeeds = React.useCallback(() => {
+    if (isWeb) {
+      navigation.navigate('Feeds')
+    } else {
+      navigation.navigate('FeedsTab')
+      navigation.popToTop()
+    }
+  }, [navigation])
+
+  return (
+    <View style={[styles.container, pal.border]}>
+      <View style={styles.inner}>
+        <Text type="xl-medium" style={[s.textCenter, pal.text]}>
+          You've reached the end of your feed! Find some more accounts to
+          follow.
+        </Text>
+        <Button
+          type="inverted"
+          style={styles.emptyBtn}
+          onPress={onPressFindAccounts}>
+          <Text type="lg-medium" style={palInverted.text}>
+            Find accounts to follow
+          </Text>
+          <FontAwesomeIcon
+            icon="angle-right"
+            style={palInverted.text as FontAwesomeIconStyle}
+            size={14}
+          />
+        </Button>
+
+        <Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}>
+          You can also discover new Custom Feeds to follow.
+        </Text>
+        <Button
+          type="inverted"
+          style={[styles.emptyBtn, s.mt10]}
+          onPress={onPressDiscoverFeeds}>
+          <Text type="lg-medium" style={palInverted.text}>
+            Discover new custom feeds
+          </Text>
+          <FontAwesomeIcon
+            icon="angle-right"
+            style={palInverted.text as FontAwesomeIconStyle}
+            size={14}
+          />
+        </Button>
+      </View>
+    </View>
+  )
+}
+const styles = StyleSheet.create({
+  container: {
+    flexDirection: 'row',
+    justifyContent: 'center',
+    paddingTop: 40,
+    paddingBottom: 80,
+    paddingHorizontal: 30,
+    borderTopWidth: 1,
+  },
+  inner: {
+    maxWidth: 460,
+  },
+  emptyBtn: {
+    marginVertical: 20,
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'space-between',
+    paddingVertical: 18,
+    paddingHorizontal: 24,
+    borderRadius: 30,
+  },
+})
diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx
index 217d326e8..adb496f6d 100644
--- a/src/view/com/profile/FollowButton.tsx
+++ b/src/view/com/profile/FollowButton.tsx
@@ -1,25 +1,26 @@
 import React from 'react'
 import {StyleProp, TextStyle, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
+import {AppBskyActorDefs} from '@atproto/api'
 import {Button, ButtonType} from '../util/forms/Button'
 import * as Toast from '../util/Toast'
 import {FollowState} from 'state/models/cache/my-follows'
-import {useFollowDid} from 'lib/hooks/useFollowDid'
+import {useFollowProfile} from 'lib/hooks/useFollowProfile'
 
 export const FollowButton = observer(function FollowButtonImpl({
   unfollowedType = 'inverted',
   followedType = 'default',
-  did,
+  profile,
   onToggleFollow,
   labelStyle,
 }: {
   unfollowedType?: ButtonType
   followedType?: ButtonType
-  did: string
+  profile: AppBskyActorDefs.ProfileViewBasic
   onToggleFollow?: (v: boolean) => void
   labelStyle?: StyleProp<TextStyle>
 }) {
-  const {state, following, toggle} = useFollowDid({did})
+  const {state, following, toggle} = useFollowProfile(profile)
 
   const onPress = React.useCallback(async () => {
     try {
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index e0c8ad21a..d1aed8934 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -200,7 +200,7 @@ export const ProfileCardWithFollowBtn = observer(
         noBorder={noBorder}
         followers={followers}
         renderButton={
-          isMe ? undefined : () => <FollowButton did={profile.did} />
+          isMe ? undefined : () => <FollowButton profile={profile} />
         }
       />
     )
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 57fa22f1e..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),
     )
@@ -392,8 +391,8 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
                     {
                       paddingHorizontal: 10,
                       backgroundColor: showSuggestedFollows
-                        ? colors.blue3
-                        : pal.viewLight.backgroundColor,
+                        ? pal.colors.text
+                        : pal.colors.backgroundLight,
                     },
                   ]}
                   accessibilityRole="button"
diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
index b9d66a6fe..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,
@@ -19,13 +19,14 @@ import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {Text} from 'view/com/util/text/Text'
 import {UserAvatar} from 'view/com/util/UserAvatar'
-import {useFollowDid} from 'lib/hooks/useFollowDid'
+import {useFollowProfile} from 'lib/hooks/useFollowProfile'
 import {Button} from 'view/com/util/forms/Button'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 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
@@ -83,7 +84,7 @@ export function ProfileHeaderSuggestedFollows({
           return []
         }
 
-        store.me.follows.hydrateProfiles(suggestions)
+        store.me.follows.hydrateMany(suggestions)
 
         return suggestions
       } catch (e) {
@@ -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 ? (
               <>
@@ -218,14 +222,14 @@ const SuggestedFollow = observer(function SuggestedFollowImpl({
   const {track} = useAnalytics()
   const pal = usePalette('default')
   const store = useStores()
-  const {following, toggle} = useFollowDid({did: profile.did})
+  const {following, toggle} = useFollowProfile(profile)
   const moderation = moderateProfile(profile, store.preferences.moderationOpts)
 
   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/search/HeaderWithInput.tsx b/src/view/com/search/HeaderWithInput.tsx
index f04175afd..6bd1b2f00 100644
--- a/src/view/com/search/HeaderWithInput.tsx
+++ b/src/view/com/search/HeaderWithInput.tsx
@@ -93,7 +93,7 @@ export function HeaderWithInput({
           onBlur={() => setIsInputFocused(false)}
           onChangeText={onChangeQuery}
           onSubmitEditing={onSubmitQuery}
-          autoFocus={isMobile}
+          autoFocus={false}
           accessibilityRole="search"
           accessibilityLabel="Search"
           accessibilityHint=""
diff --git a/src/view/com/util/EmptyState.tsx b/src/view/com/util/EmptyState.tsx
index a495fcd3f..7486b212f 100644
--- a/src/view/com/util/EmptyState.tsx
+++ b/src/view/com/util/EmptyState.tsx
@@ -22,7 +22,7 @@ export function EmptyState({
 }) {
   const pal = usePalette('default')
   return (
-    <View testID={testID} style={[styles.container, style]}>
+    <View testID={testID} style={[styles.container, pal.border, style]}>
       <View style={styles.iconContainer}>
         {icon === 'user-group' ? (
           <UserGroupIcon size="64" style={styles.icon} />
@@ -50,6 +50,7 @@ const styles = StyleSheet.create({
   container: {
     paddingVertical: 20,
     paddingHorizontal: 36,
+    borderTopWidth: 1,
   },
   iconContainer: {
     flexDirection: 'row',
diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx
index c7374e195..529435cf1 100644
--- a/src/view/com/util/ErrorBoundary.tsx
+++ b/src/view/com/util/ErrorBoundary.tsx
@@ -28,7 +28,7 @@ export class ErrorBoundary extends Component<Props, State> {
   public render() {
     if (this.state.hasError) {
       return (
-        <CenteredView>
+        <CenteredView style={{height: '100%', flex: 1}}>
           <ErrorScreen
             title="Oh no!"
             message="There was an unexpected issue in the application. Please let us know if this happened to you!"
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 472d943e1..94fe75536 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,
@@ -49,7 +48,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> {
   anchorNoUnderline?: boolean
 }
 
-export const Link = observer(function Link({
+export const Link = memo(function Link({
   testID,
   style,
   href,
@@ -135,7 +134,7 @@ export const Link = observer(function Link({
   )
 })
 
-export const TextLink = observer(function TextLink({
+export const TextLink = memo(function TextLink({
   testID,
   type = 'md',
   style,
@@ -235,7 +234,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/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index 035e29c25..6cbcddc32 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -52,20 +52,20 @@ export function AutoSizedImage({
 
   if (onPress || onLongPress || onPressIn) {
     return (
+      // disable a11y rule because in this case we want the tags on the image (#1640)
+      // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors
       <Pressable
         onPress={onPress}
         onLongPress={onLongPress}
         onPressIn={onPressIn}
-        style={[styles.container, style]}
-        accessible={true}
-        accessibilityRole="button"
-        accessibilityLabel={alt || 'Image'}
-        accessibilityHint="Tap to view fully">
+        style={[styles.container, style]}>
         <Image
           style={[styles.image, {aspectRatio}]}
           source={uri}
-          accessible={false} // Must set for `accessibilityLabel` to work
+          accessible={true} // Must set for `accessibilityLabel` to work
           accessibilityIgnoresInvertColors
+          accessibilityLabel={alt}
+          accessibilityHint="Tap to view fully"
         />
         {children}
       </Pressable>
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 e53d4a08e..ad47e9f9b 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,32 +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(
@@ -97,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}
           />
@@ -127,6 +119,7 @@ export const HomeScreen = withAuthRequired(
           isPageFocused={selectedPage === 0}
           feed={store.me.mainFeed}
           renderEmptyState={renderFollowingEmptyState}
+          renderEndOfFeed={FollowingEndOfFeed}
         />
         {customFeeds.map((f, index) => {
           return (
@@ -144,193 +137,7 @@ export const HomeScreen = withAuthRequired(
   }),
 )
 
-const FeedPage = observer(function FeedPageImpl({
-  testID,
-  isPageFocused,
-  feed,
-  renderEmptyState,
-}: {
-  testID?: string
-  feed: PostsFeedModel
-  isPageFocused: boolean
-  renderEmptyState?: () => 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}
-        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/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx
index b545a643d..b80c1667f 100644
--- a/src/view/screens/SearchMobile.tsx
+++ b/src/view/screens/SearchMobile.tsx
@@ -148,18 +148,18 @@ export const SearchScreen = withAuthRequired(
               style={pal.view}
               onScroll={onMainScroll}
               scrollEventThrottle={100}>
-              {query && autocompleteView.searchRes.length ? (
+              {query && autocompleteView.suggestions.length ? (
                 <>
-                  {autocompleteView.searchRes.map((profile, index) => (
+                  {autocompleteView.suggestions.map((suggestion, index) => (
                     <ProfileCard
-                      key={profile.did}
-                      testID={`searchAutoCompleteResult-${profile.handle}`}
-                      profile={profile}
+                      key={suggestion.did}
+                      testID={`searchAutoCompleteResult-${suggestion.handle}`}
+                      profile={suggestion}
                       noBorder={index === 0}
                     />
                   ))}
                 </>
-              ) : query && !autocompleteView.searchRes.length ? (
+              ) : query && !autocompleteView.suggestions.length ? (
                 <View>
                   <Text style={[pal.textLight, styles.searchPrompt]}>
                     No results found for {autocompleteView.prefix}
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/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/Search.tsx b/src/view/shell/desktop/Search.tsx
index dfd4f50bf..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)
@@ -90,9 +97,9 @@ export const DesktopSearch = observer(function DesktopSearch() {
 
       {query !== '' && (
         <View style={[pal.view, pal.borderDark, styles.resultsContainer]}>
-          {autocompleteView.searchRes.length ? (
+          {autocompleteView.suggestions.length ? (
             <>
-              {autocompleteView.searchRes.map((item, i) => (
+              {autocompleteView.suggestions.map((item, i) => (
                 <ProfileCard key={item.did} profile={item} noBorder={i === 0} />
               ))}
             </>
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 a4c1cbb61..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":
@@ -2255,10 +2256,10 @@
   resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
   integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==
 
-"@gorhom/bottom-sheet@^4.4.7":
-  version "4.4.7"
-  resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-4.4.7.tgz#fc80b3f0b7ebab056ce226f3aa3a89b2db8660dd"
-  integrity sha512-ukTuTqDQi2heo68hAJsBpUQeEkdqP9REBcn47OpuvPKhdPuO1RBOOADjqXJNCnZZRcY+HqbnGPMSLFVc31zylQ==
+"@gorhom/bottom-sheet@^4.5.1":
+  version "4.5.1"
+  resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-4.5.1.tgz#1ac4b234a80e7dff263f0b7ac207f92e41562849"
+  integrity sha512-4Qy6hzvN32fXu2hDxDXOIS0IBGBT6huST7J7+K1V5bXemZ08KIx5ZffyLgwhCUl+CnyeG2KG6tqk6iYLkIwi7Q==
   dependencies:
     "@gorhom/portal" "1.0.14"
     invariant "^2.2.4"
@@ -3223,44 +3224,44 @@
   resolved "https://registry.yarnpkg.com/@react-native-community/blur/-/blur-4.3.2.tgz#185a2c7dd03ba168cc95069bc4742e9505fd6c6c"
   integrity sha512-0ID+pyZKdC4RdgC7HePxUQ6JmsbNrgz03u+6SgqYpmBoK/rE+7JffqIw7IEsfoKitLEcRNLGekIBsfwCqiEkew==
 
-"@react-native-community/cli-clean@11.3.6":
-  version "11.3.6"
-  resolved "https://registry.yarnpkg.com/@react-native-community/cli-clean/-/cli-clean-11.3.6.tgz#43a06cbee1a5480da804debc4f94662a197720f2"
-  integrity sha512-jOOaeG5ebSXTHweq1NznVJVAFKtTFWL4lWgUXl845bCGX7t1lL8xQNWHKwT8Oh1pGR2CI3cKmRjY4hBg+pEI9g==
+"@react-native-community/cli-clean@11.3.7":
+  version "11.3.7"
+  resolved "https://registry.yarnpkg.com/@react-native-community/cli-clean/-/cli-clean-11.3.7.tgz#cb4c2f225f78593412c2d191b55b8570f409a48f"
+  integrity sha512-twtsv54ohcRyWVzPXL3F9VHGb4Qhn3slqqRs3wEuRzjR7cTmV2TIO2b1VhaqF4HlCgNd+cGuirvLtK2JJyaxMg==
   dependencies:
-    "@react-native-community/cli-tools" "11.3.6"
+    "@react-native-community/cli-tools" "11.3.7"
     chalk "^4.1.2"
     execa "^5.0.0"
     prompts "^2.4.0"
 
-"@react-native-community/cli-config@11.3.6":
-  version "11.3.6"
-  resolved "https://registry.yarnpkg.com/@react-native-community/cli-config/-/cli-config-11.3.6.tgz#6d3636a8a3c4542ebb123eaf61bbbc0c2a1d2a6b"
-  integrity sha512-edy7fwllSFLan/6BG6/rznOBCLPrjmJAE10FzkEqNLHowi0bckiAPg1+1jlgQ2qqAxV5kuk+c9eajVfQvPLYDA==
+"@react-native-community/cli-config@11.3.7":
+  version "11.3.7"
+  resolved "https://registry.yarnpkg.com/@react-native-community/cli-config/-/cli-config-11.3.7.tgz#4ce95548252ecb094b576369abebf9867c95d277"
+  integrity sha512-FDBLku9xskS+bx0YFJFLCmUJhEZ4/MMSC9qPYOGBollWYdgE7k/TWI0IeYFmMALAnbCdKQAYP5N29N55Tad8lg==
   dependencies:
-    "@react-native-community/cli-tools" "11.3.6"
+    "@react-native-community/cli-tools" "11.3.7"
     chalk "^4.1.2"
     cosmiconfig "^5.1.0"
     deepmerge "^4.3.0"
     glob "^7.1.3"
     joi "^17.2.1"
 
-"@react-native-community/cli-debugger-ui@11.3.6":
-  version "11.3.6"
-  resolved "https://registry.yarnpkg.com/@react-native-community/cli-debugger-ui/-/cli-debugger-ui-11.3.6.tgz#1eb2276450f270a938686b49881fe232a08c01c4"
-  integrity sha512-jhMOSN/iOlid9jn/A2/uf7HbC3u7+lGktpeGSLnHNw21iahFBzcpuO71ekEdlmTZ4zC/WyxBXw9j2ka33T358w==
+"@react-native-community/cli-debugger-ui@11.3.7":
+  version "11.3.7"
+  resolved "https://registry.yarnpkg.com/@react-native-community/cli-debugger-ui/-/cli-debugger-ui-11.3.7.tgz#2147b73313af8de3c9b396406d5d344b904cf2bb"
+  integrity sha512-aVmKuPKHZENR8SrflkMurZqeyLwbKieHdOvaZCh1Nn/0UC5CxWcyST2DB2XQboZwsvr3/WXKJkSUO+SZ1J9qTQ==
   dependencies:
     serve-static "^1.13.1"
 
-"@react-native-community/cli-doctor@11.3.6":
-  version "11.3.6"
-  resolved "https://registry.yarnpkg.com/@react-native-community/cli-doctor/-/cli-doctor-11.3.6.tgz#fa33ee00fe5120af516aa0f17fe3ad50270976e7"
-  integrity sha512-UT/Tt6omVPi1j6JEX+CObc85eVFghSZwy4GR9JFMsO7gNg2Tvcu1RGWlUkrbmWMAMHw127LUu6TGK66Ugu1NLA==
+"@react-native-community/cli-doctor@11.3.7":
+  version "11.3.7"
+  resolved "https://registry.yarnpkg.com/@react-native-community/cli-doctor/-/cli-doctor-11.3.7.tgz#7d5f5b1aea78134bba713fa97795986345ff1344"
+  integrity sha512-YEHUqWISOHnsl5+NM14KHelKh68Sr5/HeEZvvNdIcvcKtZic3FU7Xd1WcbNdo3gCq5JvzGFfufx02Tabh5zmrg==
   dependencies:
-    "@react-native-community/cli-config" "11.3.6"
-    "@react-native-community/cli-platform-android" "11.3.6"
-    "@react-native-community/cli-platform-ios" "11.3.6"
-    "@react-native-community/cli-tools" "11.3.6"
+    "@react-native-community/cli-config" "11.3.7"
+    "@react-native-community/cli-platform-android" "11.3.7"
+    "@react-native-community/cli-platform-ios" "11.3.7"
+    "@react-native-community/cli-tools" "11.3.7"
     chalk "^4.1.2"
     command-exists "^1.2.8"
     envinfo "^7.7.2"
@@ -3276,64 +3277,64 @@
     wcwidth "^1.0.1"
     yaml "^2.2.1"
 
-"@react-native-community/cli-hermes@11.3.6":
-  version "11.3.6"
-  resolved "https://registry.yarnpkg.com/@react-native-community/cli-hermes/-/cli-hermes-11.3.6.tgz#b1acc7feff66ab0859488e5812b3b3e8b8e9434c"
-  integrity sha512-O55YAYGZ3XynpUdePPVvNuUPGPY0IJdctLAOHme73OvS80gNwfntHDXfmY70TGHWIfkK2zBhA0B+2v8s5aTyTA==
+"@react-native-community/cli-hermes@11.3.7":
+  version "11.3.7"
+  resolved "https://registry.yarnpkg.com/@react-native-community/cli-hermes/-/cli-hermes-11.3.7.tgz#091e730a1f8bace6c3729e8744bad6141002e0e8"
+  integrity sha512-chkKd8n/xeZkinRvtH6QcYA8rjNOKU3S3Lw/3Psxgx+hAYV0Gyk95qJHTalx7iu+PwjOOqqvCkJo5jCkYLkoqw==
   dependencies:
-    "@react-native-community/cli-platform-android" "11.3.6"
-    "@react-native-community/cli-tools" "11.3.6"
+    "@react-native-community/cli-platform-android" "11.3.7"
+    "@react-native-community/cli-tools" "11.3.7"
     chalk "^4.1.2"
     hermes-profile-transformer "^0.0.6"
     ip "^1.1.5"
 
-"@react-native-community/cli-platform-android@11.3.6":
-  version "11.3.6"
-  resolved "https://registry.yarnpkg.com/@react-native-community/cli-platform-android/-/cli-platform-android-11.3.6.tgz#6f3581ca4eed3deec7edba83c1bc467098c8167b"
-  integrity sha512-ZARrpLv5tn3rmhZc//IuDM1LSAdYnjUmjrp58RynlvjLDI4ZEjBAGCQmgysRgXAsK7ekMrfkZgemUczfn9td2A==
+"@react-native-community/cli-platform-android@11.3.7":
+  version "11.3.7"
+  resolved "https://registry.yarnpkg.com/@react-native-community/cli-platform-android/-/cli-platform-android-11.3.7.tgz#7845bc48258b6bb55df208a23b3690647f113995"
+  integrity sha512-WGtXI/Rm178UQb8bu1TAeFC/RJvYGnbHpULXvE20GkmeJ1HIrMjkagyk6kkY3Ej25JAP2R878gv+TJ/XiRhaEg==
   dependencies:
-    "@react-native-community/cli-tools" "11.3.6"
+    "@react-native-community/cli-tools" "11.3.7"
     chalk "^4.1.2"
     execa "^5.0.0"
     glob "^7.1.3"
     logkitty "^0.7.1"
 
-"@react-native-community/cli-platform-ios@11.3.6":
-  version "11.3.6"
-  resolved "https://registry.yarnpkg.com/@react-native-community/cli-platform-ios/-/cli-platform-ios-11.3.6.tgz#0fa58d01f55d85618c4218925509a4be77867dab"
-  integrity sha512-tZ9VbXWiRW+F+fbZzpLMZlj93g3Q96HpuMsS6DRhrTiG+vMQ3o6oPWSEEmMGOvJSYU7+y68Dc9ms2liC7VD6cw==
+"@react-native-community/cli-platform-ios@11.3.7":
+  version "11.3.7"
+  resolved "https://registry.yarnpkg.com/@react-native-community/cli-platform-ios/-/cli-platform-ios-11.3.7.tgz#87478f907634713b7236c77870446a5ca1f35ff1"
+  integrity sha512-Z/8rseBput49EldX7MogvN6zJlWzZ/4M97s2P+zjS09ZoBU7I0eOKLi0N9wx+95FNBvGQQ/0P62bB9UaFQH2jw==
   dependencies:
-    "@react-native-community/cli-tools" "11.3.6"
+    "@react-native-community/cli-tools" "11.3.7"
     chalk "^4.1.2"
     execa "^5.0.0"
     fast-xml-parser "^4.0.12"
     glob "^7.1.3"
     ora "^5.4.1"
 
-"@react-native-community/cli-plugin-metro@11.3.6":
-  version "11.3.6"
-  resolved "https://registry.yarnpkg.com/@react-native-community/cli-plugin-metro/-/cli-plugin-metro-11.3.6.tgz#2d632c304313435c9ea104086901fbbeba0f1882"
-  integrity sha512-D97racrPX3069ibyabJNKw9aJpVcaZrkYiEzsEnx50uauQtPDoQ1ELb/5c6CtMhAEGKoZ0B5MS23BbsSZcLs2g==
+"@react-native-community/cli-plugin-metro@11.3.7":
+  version "11.3.7"
+  resolved "https://registry.yarnpkg.com/@react-native-community/cli-plugin-metro/-/cli-plugin-metro-11.3.7.tgz#2e8a9deb30b40495c5c1347a1837a824400fa00f"
+  integrity sha512-0WhgoBVGF1f9jXcuagQmtxpwpfP+2LbLZH4qMyo6OtYLWLG13n2uRep+8tdGzfNzl1bIuUTeE9yZSAdnf9LfYQ==
   dependencies:
-    "@react-native-community/cli-server-api" "11.3.6"
-    "@react-native-community/cli-tools" "11.3.6"
+    "@react-native-community/cli-server-api" "11.3.7"
+    "@react-native-community/cli-tools" "11.3.7"
     chalk "^4.1.2"
     execa "^5.0.0"
-    metro "0.76.7"
-    metro-config "0.76.7"
-    metro-core "0.76.7"
-    metro-react-native-babel-transformer "0.76.7"
-    metro-resolver "0.76.7"
-    metro-runtime "0.76.7"
+    metro "0.76.8"
+    metro-config "0.76.8"
+    metro-core "0.76.8"
+    metro-react-native-babel-transformer "0.76.8"
+    metro-resolver "0.76.8"
+    metro-runtime "0.76.8"
     readline "^1.3.0"
 
-"@react-native-community/cli-server-api@11.3.6":
-  version "11.3.6"
-  resolved "https://registry.yarnpkg.com/@react-native-community/cli-server-api/-/cli-server-api-11.3.6.tgz#3a16039518f7f3865f85f8f54b19174448bbcdbb"
-  integrity sha512-8GUKodPnURGtJ9JKg8yOHIRtWepPciI3ssXVw5jik7+dZ43yN8P5BqCoDaq8e1H1yRer27iiOfT7XVnwk8Dueg==
+"@react-native-community/cli-server-api@11.3.7":
+  version "11.3.7"
+  resolved "https://registry.yarnpkg.com/@react-native-community/cli-server-api/-/cli-server-api-11.3.7.tgz#2cce54b3331c9c51b9067129c297ab2e9a142216"
+  integrity sha512-yoFyGdvR3HxCnU6i9vFqKmmSqFzCbnFSnJ29a+5dppgPRetN+d//O8ard/YHqHzToFnXutAFf2neONn23qcJAg==
   dependencies:
-    "@react-native-community/cli-debugger-ui" "11.3.6"
-    "@react-native-community/cli-tools" "11.3.6"
+    "@react-native-community/cli-debugger-ui" "11.3.7"
+    "@react-native-community/cli-tools" "11.3.7"
     compression "^1.7.1"
     connect "^3.6.5"
     errorhandler "^1.5.1"
@@ -3342,10 +3343,10 @@
     serve-static "^1.13.1"
     ws "^7.5.1"
 
-"@react-native-community/cli-tools@11.3.6":
-  version "11.3.6"
-  resolved "https://registry.yarnpkg.com/@react-native-community/cli-tools/-/cli-tools-11.3.6.tgz#ec213b8409917a56e023595f148c84b9cb3ad871"
-  integrity sha512-JpmUTcDwAGiTzLsfMlIAYpCMSJ9w2Qlf7PU7mZIRyEu61UzEawyw83DkqfbzDPBuRwRnaeN44JX2CP/yTO3ThQ==
+"@react-native-community/cli-tools@11.3.7":
+  version "11.3.7"
+  resolved "https://registry.yarnpkg.com/@react-native-community/cli-tools/-/cli-tools-11.3.7.tgz#37aa7efc7b4a1b7077d541f1d7bb11a2ab7b6ff2"
+  integrity sha512-peyhP4TV6Ps1hk+MBHTFaIR1eI3u+OfGBvr5r0wPwo3FAJvldRinMgcB/TcCcOBXVORu7ba1XYjkubPeYcqAyA==
   dependencies:
     appdirsjs "^1.2.4"
     chalk "^4.1.2"
@@ -3357,27 +3358,27 @@
     semver "^7.5.2"
     shell-quote "^1.7.3"
 
-"@react-native-community/cli-types@11.3.6":
-  version "11.3.6"
-  resolved "https://registry.yarnpkg.com/@react-native-community/cli-types/-/cli-types-11.3.6.tgz#34012f1d0cb1c4039268828abc07c9c69f2e15be"
-  integrity sha512-6DxjrMKx5x68N/tCJYVYRKAtlRHbtUVBZrnAvkxbRWFD9v4vhNgsPM0RQm8i2vRugeksnao5mbnRGpS6c0awCw==
+"@react-native-community/cli-types@11.3.7":
+  version "11.3.7"
+  resolved "https://registry.yarnpkg.com/@react-native-community/cli-types/-/cli-types-11.3.7.tgz#12fe7cff3da08bd27e11116531b2e001939854b9"
+  integrity sha512-OhSr/TiDQkXjL5YOs8+hvGSB+HltLn5ZI0+A3DCiMsjUgTTsYh+Z63OtyMpNjrdCEFcg0MpfdU2uxstCS6Dc5g==
   dependencies:
     joi "^17.2.1"
 
-"@react-native-community/cli@11.3.6":
-  version "11.3.6"
-  resolved "https://registry.yarnpkg.com/@react-native-community/cli/-/cli-11.3.6.tgz#d92618d75229eaf6c0391a6b075684eba5d9819f"
-  integrity sha512-bdwOIYTBVQ9VK34dsf6t3u6vOUU5lfdhKaAxiAVArjsr7Je88Bgs4sAbsOYsNK3tkE8G77U6wLpekknXcanlww==
-  dependencies:
-    "@react-native-community/cli-clean" "11.3.6"
-    "@react-native-community/cli-config" "11.3.6"
-    "@react-native-community/cli-debugger-ui" "11.3.6"
-    "@react-native-community/cli-doctor" "11.3.6"
-    "@react-native-community/cli-hermes" "11.3.6"
-    "@react-native-community/cli-plugin-metro" "11.3.6"
-    "@react-native-community/cli-server-api" "11.3.6"
-    "@react-native-community/cli-tools" "11.3.6"
-    "@react-native-community/cli-types" "11.3.6"
+"@react-native-community/cli@11.3.7":
+  version "11.3.7"
+  resolved "https://registry.yarnpkg.com/@react-native-community/cli/-/cli-11.3.7.tgz#564c0054269d8385fa9d301750b2e56dbb5c0cc9"
+  integrity sha512-Ou8eDlF+yh2rzXeCTpMPYJ2fuqsusNOhmpYPYNQJQ2h6PvaF30kPomflgRILems+EBBuggRtcT+I+1YH4o/q6w==
+  dependencies:
+    "@react-native-community/cli-clean" "11.3.7"
+    "@react-native-community/cli-config" "11.3.7"
+    "@react-native-community/cli-debugger-ui" "11.3.7"
+    "@react-native-community/cli-doctor" "11.3.7"
+    "@react-native-community/cli-hermes" "11.3.7"
+    "@react-native-community/cli-plugin-metro" "11.3.7"
+    "@react-native-community/cli-server-api" "11.3.7"
+    "@react-native-community/cli-tools" "11.3.7"
+    "@react-native-community/cli-types" "11.3.7"
     chalk "^4.1.2"
     commander "^9.4.1"
     execa "^5.0.0"
@@ -3438,10 +3439,10 @@
   resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.72.0.tgz#c82a76a1d86ec0c3907be76f7faf97a32bbed05d"
   integrity sha512-Im93xRJuHHxb1wniGhBMsxLwcfzdYreSZVQGDoMJgkd6+Iky61LInGEHnQCTN0fKNYF1Dvcofb4uMmE1RQHXHQ==
 
-"@react-native/codegen@^0.72.6":
-  version "0.72.6"
-  resolved "https://registry.yarnpkg.com/@react-native/codegen/-/codegen-0.72.6.tgz#029cf61f82f5c6872f0b2ce58f27c4239a5586c8"
-  integrity sha512-idTVI1es/oopN0jJT/0jB6nKdvTUKE3757zA5+NPXZTeB46CIRbmmos4XBiAec8ufu9/DigLPbHTYAaMNZJ6Ig==
+"@react-native/codegen@^0.72.7":
+  version "0.72.7"
+  resolved "https://registry.yarnpkg.com/@react-native/codegen/-/codegen-0.72.7.tgz#b6832ce631ac63143024ea094a6b5480a780e589"
+  integrity sha512-O7xNcGeXGbY+VoqBGNlZ3O05gxfATlwE1Q1qQf5E38dK+tXn5BY4u0jaQ9DPjfE8pBba8g/BYI1N44lynidMtg==
   dependencies:
     "@babel/parser" "^7.20.0"
     flow-parser "^0.206.0"
@@ -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"
@@ -13003,53 +13118,53 @@ methods@~1.1.2:
   resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
   integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
 
-metro-babel-transformer@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-babel-transformer/-/metro-babel-transformer-0.76.7.tgz#ba620d64cbaf97d1aa14146d654a3e5d7477fc62"
-  integrity sha512-bgr2OFn0J4r0qoZcHrwEvccF7g9k3wdgTOgk6gmGHrtlZ1Jn3oCpklW/DfZ9PzHfjY2mQammKTc19g/EFGyOJw==
+metro-babel-transformer@0.76.8:
+  version "0.76.8"
+  resolved "https://registry.yarnpkg.com/metro-babel-transformer/-/metro-babel-transformer-0.76.8.tgz#5efd1027353b36b73706164ef09c290dceac096a"
+  integrity sha512-Hh6PW34Ug/nShlBGxkwQJSgPGAzSJ9FwQXhUImkzdsDgVu6zj5bx258J8cJVSandjNoQ8nbaHK6CaHlnbZKbyA==
   dependencies:
     "@babel/core" "^7.20.0"
     hermes-parser "0.12.0"
     nullthrows "^1.1.1"
 
-metro-cache-key@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-cache-key/-/metro-cache-key-0.76.7.tgz#70913f43b92b313096673c37532edd07438cb325"
-  integrity sha512-0pecoIzwsD/Whn/Qfa+SDMX2YyasV0ndbcgUFx7w1Ct2sLHClujdhQ4ik6mvQmsaOcnGkIyN0zcceMDjC2+BFQ==
+metro-cache-key@0.76.8:
+  version "0.76.8"
+  resolved "https://registry.yarnpkg.com/metro-cache-key/-/metro-cache-key-0.76.8.tgz#8a0a5e991c06f56fcc584acadacb313c312bdc16"
+  integrity sha512-buKQ5xentPig9G6T37Ww/R/bC+/V1MA5xU/D8zjnhlelsrPG6w6LtHUS61ID3zZcMZqYaELWk5UIadIdDsaaLw==
 
-metro-cache@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-cache/-/metro-cache-0.76.7.tgz#e49e51423fa960df4eeff9760d131f03e003a9eb"
-  integrity sha512-nWBMztrs5RuSxZRI7hgFgob5PhYDmxICh9FF8anm9/ito0u0vpPvRxt7sRu8fyeD2AHdXqE7kX32rWY0LiXgeg==
+metro-cache@0.76.8:
+  version "0.76.8"
+  resolved "https://registry.yarnpkg.com/metro-cache/-/metro-cache-0.76.8.tgz#296c1c189db2053b89735a8f33dbe82575f53661"
+  integrity sha512-QBJSJIVNH7Hc/Yo6br/U/qQDUpiUdRgZ2ZBJmvAbmAKp2XDzsapnMwK/3BGj8JNWJF7OLrqrYHsRsukSbUBpvQ==
   dependencies:
-    metro-core "0.76.7"
+    metro-core "0.76.8"
     rimraf "^3.0.2"
 
-metro-config@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.76.7.tgz#f0fc171707523aa7d3a9311550872136880558c0"
-  integrity sha512-CFDyNb9bqxZemiChC/gNdXZ7OQkIwmXzkrEXivcXGbgzlt/b2juCv555GWJHyZSlorwnwJfY3uzAFu4A9iRVfg==
+metro-config@0.76.8:
+  version "0.76.8"
+  resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.76.8.tgz#20bd5397fcc6096f98d2a813a7cecb38b8af062d"
+  integrity sha512-SL1lfKB0qGHALcAk2zBqVgQZpazDYvYFGwCK1ikz0S6Y/CM2i2/HwuZN31kpX6z3mqjv/6KvlzaKoTb1otuSAA==
   dependencies:
     connect "^3.6.5"
     cosmiconfig "^5.0.5"
     jest-validate "^29.2.1"
-    metro "0.76.7"
-    metro-cache "0.76.7"
-    metro-core "0.76.7"
-    metro-runtime "0.76.7"
+    metro "0.76.8"
+    metro-cache "0.76.8"
+    metro-core "0.76.8"
+    metro-runtime "0.76.8"
 
-metro-core@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.76.7.tgz#5d2b8bac2cde801dc22666ad7be1336d1f021b61"
-  integrity sha512-0b8KfrwPmwCMW+1V7ZQPkTy2tsEKZjYG9Pu1PTsu463Z9fxX7WaR0fcHFshv+J1CnQSUTwIGGjbNvj1teKe+pw==
+metro-core@0.76.8:
+  version "0.76.8"
+  resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.76.8.tgz#917c8157c63406cb223522835abb8e7c6291dcad"
+  integrity sha512-sl2QLFI3d1b1XUUGxwzw/KbaXXU/bvFYrSKz6Sg19AdYGWFyzsgZ1VISRIDf+HWm4R/TJXluhWMEkEtZuqi3qA==
   dependencies:
     lodash.throttle "^4.1.1"
-    metro-resolver "0.76.7"
+    metro-resolver "0.76.8"
 
-metro-file-map@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-file-map/-/metro-file-map-0.76.7.tgz#0f041a4f186ac672f0188180310609c8483ffe89"
-  integrity sha512-s+zEkTcJ4mOJTgEE2ht4jIo1DZfeWreQR3tpT3gDV/Y/0UQ8aJBTv62dE775z0GLsWZApiblAYZsj7ZE8P06nw==
+metro-file-map@0.76.8:
+  version "0.76.8"
+  resolved "https://registry.yarnpkg.com/metro-file-map/-/metro-file-map-0.76.8.tgz#a1db1185b6c316904ba6b53d628e5d1323991d79"
+  integrity sha512-A/xP1YNEVwO1SUV9/YYo6/Y1MmzhL4ZnVgcJC3VmHp/BYVOXVStzgVbWv2wILe56IIMkfXU+jpXrGKKYhFyHVw==
   dependencies:
     anymatch "^3.0.3"
     debug "^2.2.0"
@@ -13066,10 +13181,10 @@ metro-file-map@0.76.7:
   optionalDependencies:
     fsevents "^2.3.2"
 
-metro-inspector-proxy@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-inspector-proxy/-/metro-inspector-proxy-0.76.7.tgz#c067df25056e932002a72a4b45cf7b4b749f808e"
-  integrity sha512-rNZ/6edTl/1qUekAhAbaFjczMphM50/UjtxiKulo6vqvgn/Mjd9hVqDvVYfAMZXqPvlusD88n38UjVYPkruLSg==
+metro-inspector-proxy@0.76.8:
+  version "0.76.8"
+  resolved "https://registry.yarnpkg.com/metro-inspector-proxy/-/metro-inspector-proxy-0.76.8.tgz#6b8678a7461b0b42f913a7881cc9319b4d3cddff"
+  integrity sha512-Us5o5UEd4Smgn1+TfHX4LvVPoWVo9VsVMn4Ldbk0g5CQx3Gu0ygc/ei2AKPGTwsOZmKxJeACj7yMH2kgxQP/iw==
   dependencies:
     connect "^3.6.5"
     debug "^2.2.0"
@@ -13077,65 +13192,20 @@ metro-inspector-proxy@0.76.7:
     ws "^7.5.1"
     yargs "^17.6.2"
 
-metro-minify-terser@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-minify-terser/-/metro-minify-terser-0.76.7.tgz#aefac8bb8b6b3a0fcb5ea0238623cf3e100893ff"
-  integrity sha512-FQiZGhIxCzhDwK4LxyPMLlq0Tsmla10X7BfNGlYFK0A5IsaVKNJbETyTzhpIwc+YFRT4GkFFwgo0V2N5vxO5HA==
+metro-minify-terser@0.76.8:
+  version "0.76.8"
+  resolved "https://registry.yarnpkg.com/metro-minify-terser/-/metro-minify-terser-0.76.8.tgz#915ab4d1419257fc6a0b9fa15827b83fe69814bf"
+  integrity sha512-Orbvg18qXHCrSj1KbaeSDVYRy/gkro2PC7Fy2tDSH1c9RB4aH8tuMOIXnKJE+1SXxBtjWmQ5Yirwkth2DyyEZA==
   dependencies:
     terser "^5.15.0"
 
-metro-minify-uglify@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-minify-uglify/-/metro-minify-uglify-0.76.7.tgz#3e0143786718dcaea4e28a724698d4f8ac199a43"
-  integrity sha512-FuXIU3j2uNcSvQtPrAJjYWHruPiQ+EpE++J9Z+VznQKEHcIxMMoQZAfIF2IpZSrZYfLOjVFyGMvj41jQMxV1Vw==
+metro-minify-uglify@0.76.8:
+  version "0.76.8"
+  resolved "https://registry.yarnpkg.com/metro-minify-uglify/-/metro-minify-uglify-0.76.8.tgz#74745045ea2dd29f8783db483b2fce58385ba695"
+  integrity sha512-6l8/bEvtVaTSuhG1FqS0+Mc8lZ3Bl4RI8SeRIifVLC21eeSDp4CEBUWSGjpFyUDfi6R5dXzYaFnSgMNyfxADiQ==
   dependencies:
     uglify-es "^3.1.9"
 
-metro-react-native-babel-preset@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-react-native-babel-preset/-/metro-react-native-babel-preset-0.76.7.tgz#dfe15c040d0918147a8b0e9f530d558287acbb54"
-  integrity sha512-R25wq+VOSorAK3hc07NW0SmN8z9S/IR0Us0oGAsBcMZnsgkbOxu77Mduqf+f4is/wnWHc5+9bfiqdLnaMngiVw==
-  dependencies:
-    "@babel/core" "^7.20.0"
-    "@babel/plugin-proposal-async-generator-functions" "^7.0.0"
-    "@babel/plugin-proposal-class-properties" "^7.18.0"
-    "@babel/plugin-proposal-export-default-from" "^7.0.0"
-    "@babel/plugin-proposal-nullish-coalescing-operator" "^7.18.0"
-    "@babel/plugin-proposal-numeric-separator" "^7.0.0"
-    "@babel/plugin-proposal-object-rest-spread" "^7.20.0"
-    "@babel/plugin-proposal-optional-catch-binding" "^7.0.0"
-    "@babel/plugin-proposal-optional-chaining" "^7.20.0"
-    "@babel/plugin-syntax-dynamic-import" "^7.8.0"
-    "@babel/plugin-syntax-export-default-from" "^7.0.0"
-    "@babel/plugin-syntax-flow" "^7.18.0"
-    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.0.0"
-    "@babel/plugin-syntax-optional-chaining" "^7.0.0"
-    "@babel/plugin-transform-arrow-functions" "^7.0.0"
-    "@babel/plugin-transform-async-to-generator" "^7.20.0"
-    "@babel/plugin-transform-block-scoping" "^7.0.0"
-    "@babel/plugin-transform-classes" "^7.0.0"
-    "@babel/plugin-transform-computed-properties" "^7.0.0"
-    "@babel/plugin-transform-destructuring" "^7.20.0"
-    "@babel/plugin-transform-flow-strip-types" "^7.20.0"
-    "@babel/plugin-transform-function-name" "^7.0.0"
-    "@babel/plugin-transform-literals" "^7.0.0"
-    "@babel/plugin-transform-modules-commonjs" "^7.0.0"
-    "@babel/plugin-transform-named-capturing-groups-regex" "^7.0.0"
-    "@babel/plugin-transform-parameters" "^7.0.0"
-    "@babel/plugin-transform-react-display-name" "^7.0.0"
-    "@babel/plugin-transform-react-jsx" "^7.0.0"
-    "@babel/plugin-transform-react-jsx-self" "^7.0.0"
-    "@babel/plugin-transform-react-jsx-source" "^7.0.0"
-    "@babel/plugin-transform-runtime" "^7.0.0"
-    "@babel/plugin-transform-shorthand-properties" "^7.0.0"
-    "@babel/plugin-transform-spread" "^7.0.0"
-    "@babel/plugin-transform-sticky-regex" "^7.0.0"
-    "@babel/plugin-transform-typescript" "^7.5.0"
-    "@babel/plugin-transform-unicode-regex" "^7.0.0"
-    "@babel/template" "^7.0.0"
-    babel-plugin-transform-flow-enums "^0.0.2"
-    react-refresh "^0.4.0"
-
 metro-react-native-babel-preset@0.76.8:
   version "0.76.8"
   resolved "https://registry.yarnpkg.com/metro-react-native-babel-preset/-/metro-react-native-babel-preset-0.76.8.tgz#7476efae14363cbdfeeec403b4f01d7348e6c048"
@@ -13225,29 +13295,21 @@ metro-react-native-babel-preset@^0.73.7:
     "@babel/template" "^7.0.0"
     react-refresh "^0.4.0"
 
-metro-react-native-babel-transformer@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-react-native-babel-transformer/-/metro-react-native-babel-transformer-0.76.7.tgz#ccc7c25b49ee8a1860aafdbf48bfa5441d206f8f"
-  integrity sha512-W6lW3J7y/05ph3c2p3KKJNhH0IdyxdOCbQ5it7aM2MAl0SM4wgKjaV6EYv9b3rHklpV6K3qMH37UKVcjMooWiA==
+metro-react-native-babel-transformer@0.76.8:
+  version "0.76.8"
+  resolved "https://registry.yarnpkg.com/metro-react-native-babel-transformer/-/metro-react-native-babel-transformer-0.76.8.tgz#c3a98e1f4cd5faf1e21eba8e004b94a90c4db69b"
+  integrity sha512-3h+LfS1WG1PAzhq8QF0kfXjxuXetbY/lgz8vYMQhgrMMp17WM1DNJD0gjx8tOGYbpbBC1qesJ45KMS4o5TA73A==
   dependencies:
     "@babel/core" "^7.20.0"
     babel-preset-fbjs "^3.4.0"
     hermes-parser "0.12.0"
-    metro-react-native-babel-preset "0.76.7"
+    metro-react-native-babel-preset "0.76.8"
     nullthrows "^1.1.1"
 
-metro-resolver@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-resolver/-/metro-resolver-0.76.7.tgz#f00ebead64e451c060f30926ecbf4f797588df52"
-  integrity sha512-pC0Wgq29HHIHrwz23xxiNgylhI8Rq1V01kQaJ9Kz11zWrIdlrH0ZdnJ7GC6qA0ErROG+cXmJ0rJb8/SW1Zp2IA==
-
-metro-runtime@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-runtime/-/metro-runtime-0.76.7.tgz#4d75f2dbbcd19a4f01e0d89494e140b0ba8247e4"
-  integrity sha512-MuWHubQHymUWBpZLwuKZQgA/qbb35WnDAKPo83rk7JRLIFPvzXSvFaC18voPuzJBt1V98lKQIonh6MiC9gd8Ug==
-  dependencies:
-    "@babel/runtime" "^7.0.0"
-    react-refresh "^0.4.0"
+metro-resolver@0.76.8:
+  version "0.76.8"
+  resolved "https://registry.yarnpkg.com/metro-resolver/-/metro-resolver-0.76.8.tgz#0862755b9b84e26853978322464fb37c6fdad76d"
+  integrity sha512-KccOqc10vrzS7ZhG2NSnL2dh3uVydarB7nOhjreQ7C4zyWuiW9XpLC4h47KtGQv3Rnv/NDLJYeDqaJ4/+140HQ==
 
 metro-runtime@0.76.8:
   version "0.76.8"
@@ -13257,20 +13319,6 @@ metro-runtime@0.76.8:
     "@babel/runtime" "^7.0.0"
     react-refresh "^0.4.0"
 
-metro-source-map@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.76.7.tgz#9a4aa3a35e1e8ffde9a74cd7ab5f49d9d4a4da14"
-  integrity sha512-Prhx7PeRV1LuogT0Kn5VjCuFu9fVD68eefntdWabrksmNY6mXK8pRqzvNJOhTojh6nek+RxBzZeD6MIOOyXS6w==
-  dependencies:
-    "@babel/traverse" "^7.20.0"
-    "@babel/types" "^7.20.0"
-    invariant "^2.2.4"
-    metro-symbolicate "0.76.7"
-    nullthrows "^1.1.1"
-    ob1 "0.76.7"
-    source-map "^0.5.6"
-    vlq "^1.0.0"
-
 metro-source-map@0.76.8:
   version "0.76.8"
   resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.76.8.tgz#f085800152a6ba0b41ca26833874d31ec36c5a53"
@@ -13285,18 +13333,6 @@ metro-source-map@0.76.8:
     source-map "^0.5.6"
     vlq "^1.0.0"
 
-metro-symbolicate@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-symbolicate/-/metro-symbolicate-0.76.7.tgz#1720e6b4ce5676935d7a8a440f25d3f16638e87a"
-  integrity sha512-p0zWEME5qLSL1bJb93iq+zt5fz3sfVn9xFYzca1TJIpY5MommEaS64Va87lp56O0sfEIvh4307Oaf/ZzRjuLiQ==
-  dependencies:
-    invariant "^2.2.4"
-    metro-source-map "0.76.7"
-    nullthrows "^1.1.1"
-    source-map "^0.5.6"
-    through2 "^2.0.1"
-    vlq "^1.0.0"
-
 metro-symbolicate@0.76.8:
   version "0.76.8"
   resolved "https://registry.yarnpkg.com/metro-symbolicate/-/metro-symbolicate-0.76.8.tgz#f102ac1a306d51597ecc8fdf961c0a88bddbca03"
@@ -13309,10 +13345,10 @@ metro-symbolicate@0.76.8:
     through2 "^2.0.1"
     vlq "^1.0.0"
 
-metro-transform-plugins@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-transform-plugins/-/metro-transform-plugins-0.76.7.tgz#5d5f75371706fbf5166288e43ffd36b5e5bd05bc"
-  integrity sha512-iSmnjVApbdivjuzb88Orb0JHvcEt5veVyFAzxiS5h0QB+zV79w6JCSqZlHCrbNOkOKBED//LqtKbFVakxllnNg==
+metro-transform-plugins@0.76.8:
+  version "0.76.8"
+  resolved "https://registry.yarnpkg.com/metro-transform-plugins/-/metro-transform-plugins-0.76.8.tgz#d77c28a6547a8e3b72250f740fcfbd7f5408f8ba"
+  integrity sha512-PlkGTQNqS51Bx4vuufSQCdSn2R2rt7korzngo+b5GCkeX5pjinPjnO2kNhQ8l+5bO0iUD/WZ9nsM2PGGKIkWFA==
   dependencies:
     "@babel/core" "^7.20.0"
     "@babel/generator" "^7.20.0"
@@ -13320,28 +13356,28 @@ metro-transform-plugins@0.76.7:
     "@babel/traverse" "^7.20.0"
     nullthrows "^1.1.1"
 
-metro-transform-worker@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro-transform-worker/-/metro-transform-worker-0.76.7.tgz#b842d5a542f1806cca401633fc002559b3e3d668"
-  integrity sha512-cGvELqFMVk9XTC15CMVzrCzcO6sO1lURfcbgjuuPdzaWuD11eEyocvkTX0DPiRjsvgAmicz4XYxVzgYl3MykDw==
+metro-transform-worker@0.76.8:
+  version "0.76.8"
+  resolved "https://registry.yarnpkg.com/metro-transform-worker/-/metro-transform-worker-0.76.8.tgz#b9012a196cee205170d0c899b8b175b9305acdea"
+  integrity sha512-mE1fxVAnJKmwwJyDtThildxxos9+DGs9+vTrx2ktSFMEVTtXS/bIv2W6hux1pqivqAfyJpTeACXHk5u2DgGvIQ==
   dependencies:
     "@babel/core" "^7.20.0"
     "@babel/generator" "^7.20.0"
     "@babel/parser" "^7.20.0"
     "@babel/types" "^7.20.0"
     babel-preset-fbjs "^3.4.0"
-    metro "0.76.7"
-    metro-babel-transformer "0.76.7"
-    metro-cache "0.76.7"
-    metro-cache-key "0.76.7"
-    metro-source-map "0.76.7"
-    metro-transform-plugins "0.76.7"
+    metro "0.76.8"
+    metro-babel-transformer "0.76.8"
+    metro-cache "0.76.8"
+    metro-cache-key "0.76.8"
+    metro-source-map "0.76.8"
+    metro-transform-plugins "0.76.8"
     nullthrows "^1.1.1"
 
-metro@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/metro/-/metro-0.76.7.tgz#4885917ad28738c7d1e556630e0155f687336230"
-  integrity sha512-67ZGwDeumEPnrHI+pEDSKH2cx+C81Gx8Mn5qOtmGUPm/Up9Y4I1H2dJZ5n17MWzejNo0XAvPh0QL0CrlJEODVQ==
+metro@0.76.8:
+  version "0.76.8"
+  resolved "https://registry.yarnpkg.com/metro/-/metro-0.76.8.tgz#ba526808b99977ca3f9ac5a7432fd02a340d13a6"
+  integrity sha512-oQA3gLzrrYv3qKtuWArMgHPbHu8odZOD9AoavrqSFllkPgOtmkBvNNDLCELqv5SjBfqjISNffypg+5UGG3y0pg==
   dependencies:
     "@babel/code-frame" "^7.0.0"
     "@babel/core" "^7.20.0"
@@ -13365,22 +13401,22 @@ metro@0.76.7:
     jest-worker "^27.2.0"
     jsc-safe-url "^0.2.2"
     lodash.throttle "^4.1.1"
-    metro-babel-transformer "0.76.7"
-    metro-cache "0.76.7"
-    metro-cache-key "0.76.7"
-    metro-config "0.76.7"
-    metro-core "0.76.7"
-    metro-file-map "0.76.7"
-    metro-inspector-proxy "0.76.7"
-    metro-minify-terser "0.76.7"
-    metro-minify-uglify "0.76.7"
-    metro-react-native-babel-preset "0.76.7"
-    metro-resolver "0.76.7"
-    metro-runtime "0.76.7"
-    metro-source-map "0.76.7"
-    metro-symbolicate "0.76.7"
-    metro-transform-plugins "0.76.7"
-    metro-transform-worker "0.76.7"
+    metro-babel-transformer "0.76.8"
+    metro-cache "0.76.8"
+    metro-cache-key "0.76.8"
+    metro-config "0.76.8"
+    metro-core "0.76.8"
+    metro-file-map "0.76.8"
+    metro-inspector-proxy "0.76.8"
+    metro-minify-terser "0.76.8"
+    metro-minify-uglify "0.76.8"
+    metro-react-native-babel-preset "0.76.8"
+    metro-resolver "0.76.8"
+    metro-runtime "0.76.8"
+    metro-source-map "0.76.8"
+    metro-symbolicate "0.76.8"
+    metro-transform-plugins "0.76.8"
+    metro-transform-worker "0.76.8"
     mime-types "^2.1.27"
     node-fetch "^2.2.0"
     nullthrows "^1.1.1"
@@ -13857,11 +13893,6 @@ nwsapi@^2.2.0, nwsapi@^2.2.2:
   resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30"
   integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==
 
-ob1@0.76.7:
-  version "0.76.7"
-  resolved "https://registry.yarnpkg.com/ob1/-/ob1-0.76.7.tgz#95b68fadafd47e7a6a0ad64cf80f3140dd6d1124"
-  integrity sha512-BQdRtxxoUNfSoZxqeBGOyuT9nEYSn18xZHwGMb0mMVpn2NBcYbnyKY4BK2LIHRgw33CBGlUmE+KMaNvyTpLLtQ==
-
 ob1@0.76.8:
   version "0.76.8"
   resolved "https://registry.yarnpkg.com/ob1/-/ob1-0.76.8.tgz#ac4c459465b1c0e2c29aaa527e09fc463d3ffec8"
@@ -15931,17 +15962,17 @@ react-native-web@~0.19.6:
     postcss-value-parser "^4.2.0"
     styleq "^0.1.3"
 
-react-native@0.72.4:
-  version "0.72.4"
-  resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.72.4.tgz#97b57e22e4d7657eaf4d1f62a678511fcf9bdda7"
-  integrity sha512-+vrObi0wZR+NeqL09KihAAdVlQ9IdplwznJWtYrjnQ4UbCW6rkzZJebRsugwUneSOKNFaHFEo1uKU89HsgtYBg==
+react-native@0.72.5:
+  version "0.72.5"
+  resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.72.5.tgz#2c343fa6f3ead362cf07376634a33a4078864357"
+  integrity sha512-oIewslu5DBwOmo7x5rdzZlZXCqDIna0R4dUwVpfmVteORYLr4yaZo5wQnMeR+H7x54GaMhmgeqp0ZpULtulJFg==
   dependencies:
     "@jest/create-cache-key-function" "^29.2.1"
-    "@react-native-community/cli" "11.3.6"
-    "@react-native-community/cli-platform-android" "11.3.6"
-    "@react-native-community/cli-platform-ios" "11.3.6"
+    "@react-native-community/cli" "11.3.7"
+    "@react-native-community/cli-platform-android" "11.3.7"
+    "@react-native-community/cli-platform-ios" "11.3.7"
     "@react-native/assets-registry" "^0.72.0"
-    "@react-native/codegen" "^0.72.6"
+    "@react-native/codegen" "^0.72.7"
     "@react-native/gradle-plugin" "^0.72.11"
     "@react-native/js-polyfills" "^0.72.1"
     "@react-native/normalize-colors" "^0.72.0"
@@ -16788,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==
@@ -17974,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==
@@ -19275,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"