about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--bskyweb/templates/base.html14
-rw-r--r--jest/jestSetup.js5
-rw-r--r--modules/bottom-sheet/android/build.gradle49
-rw-r--r--modules/bottom-sheet/android/src/main/AndroidManifest.xml2
-rw-r--r--modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt53
-rw-r--r--modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt339
-rw-r--r--modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt171
-rw-r--r--modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt28
-rw-r--r--modules/bottom-sheet/expo-module.config.json9
-rw-r--r--modules/bottom-sheet/index.ts13
-rw-r--r--modules/bottom-sheet/ios/BottomSheet.podspec21
-rw-r--r--modules/bottom-sheet/ios/BottomSheetModule.swift47
-rw-r--r--modules/bottom-sheet/ios/SheetManager.swift28
-rw-r--r--modules/bottom-sheet/ios/SheetView.swift189
-rw-r--r--modules/bottom-sheet/ios/SheetViewController.swift76
-rw-r--r--modules/bottom-sheet/ios/Util.swift18
-rw-r--r--modules/bottom-sheet/src/BottomSheet.tsx100
-rw-r--r--modules/bottom-sheet/src/BottomSheet.types.ts35
-rw-r--r--modules/bottom-sheet/src/BottomSheet.web.tsx5
-rw-r--r--package.json5
-rw-r--r--patches/react-native+0.74.1.patch80
-rw-r--r--src/App.native.tsx4
-rw-r--r--src/alf/fonts.ts2
-rw-r--r--src/alf/util/useColorModeTheme.ts6
-rw-r--r--src/components/Button.tsx7
-rw-r--r--src/components/Dialog/context.ts5
-rw-r--r--src/components/Dialog/index.tsx380
-rw-r--r--src/components/Dialog/index.web.tsx12
-rw-r--r--src/components/Dialog/sheet-wrapper.ts20
-rw-r--r--src/components/Dialog/types.ts13
-rw-r--r--src/components/KeyboardControllerPadding.android.tsx31
-rw-r--r--src/components/KeyboardControllerPadding.tsx7
-rw-r--r--src/components/LikesDialog.tsx16
-rw-r--r--src/components/Link.tsx6
-rw-r--r--src/components/Menu/index.tsx21
-rw-r--r--src/components/NewskieDialog.tsx6
-rw-r--r--src/components/Portal.tsx2
-rw-r--r--src/components/Prompt.tsx18
-rw-r--r--src/components/ReportDialog/SelectLabelerView.tsx1
-rw-r--r--src/components/ReportDialog/SubmitView.tsx3
-rw-r--r--src/components/ReportDialog/index.tsx9
-rw-r--r--src/components/StarterPack/QrCodeDialog.tsx1
-rw-r--r--src/components/StarterPack/ShareDialog.tsx16
-rw-r--r--src/components/StarterPack/Wizard/WizardEditListDialog.tsx26
-rw-r--r--src/components/TagMenu/index.tsx1
-rw-r--r--src/components/Typography.tsx31
-rw-r--r--src/components/dialogs/BirthDateSettings.tsx1
-rw-r--r--src/components/dialogs/EmbedConsent.tsx1
-rw-r--r--src/components/dialogs/GifSelect.ios.tsx255
-rw-r--r--src/components/dialogs/GifSelect.shared.tsx53
-rw-r--r--src/components/dialogs/GifSelect.tsx75
-rw-r--r--src/components/dialogs/MutedWords.tsx528
-rw-r--r--src/components/dialogs/PostInteractionSettingsDialog.tsx7
-rw-r--r--src/components/dialogs/SwitchAccount.tsx1
-rw-r--r--src/components/dialogs/nuxs/NeueTypography.tsx1
-rw-r--r--src/components/dms/ConvoMenu.tsx6
-rw-r--r--src/components/dms/MessageMenu.tsx10
-rw-r--r--src/components/dms/ReportDialog.tsx7
-rw-r--r--src/components/dms/dialogs/NewChatDialog.tsx8
-rw-r--r--src/components/dms/dialogs/SearchablePeopleList.tsx101
-rw-r--r--src/components/dms/dialogs/ShareViaChatDialog.tsx8
-rw-r--r--src/components/forms/Toggle.tsx2
-rw-r--r--src/components/moderation/LabelsOnMeDialog.tsx71
-rw-r--r--src/components/moderation/ModerationDetailsDialog.tsx33
-rw-r--r--src/lib/api/index.ts32
-rw-r--r--src/lib/media/video/upload.ts2
-rw-r--r--src/lib/media/video/upload.web.ts2
-rw-r--r--src/screens/Onboarding/StepProfile/index.tsx37
-rw-r--r--src/screens/StarterPack/StarterPackScreen.tsx44
-rw-r--r--src/state/dialogs/index.tsx87
-rw-r--r--src/state/preferences/in-app-browser.tsx12
-rw-r--r--src/style.css15
-rw-r--r--src/view/com/auth/server-input/index.tsx6
-rw-r--r--src/view/com/composer/Composer.tsx568
-rw-r--r--src/view/com/composer/GifAltText.tsx9
-rw-r--r--src/view/com/composer/photos/EditImageDialog.web.tsx1
-rw-r--r--src/view/com/composer/photos/Gallery.tsx13
-rw-r--r--src/view/com/composer/photos/ImageAltTextDialog.tsx9
-rw-r--r--src/view/com/composer/photos/SelectGifBtn.tsx5
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx14
-rw-r--r--src/view/com/composer/state/composer.ts151
-rw-r--r--src/view/com/composer/state/video.ts48
-rw-r--r--src/view/com/composer/threadgate/ThreadgateBtn.tsx5
-rw-r--r--src/view/com/composer/videos/SubtitleDialog.tsx20
-rw-r--r--src/view/com/post-thread/PostThreadComposePrompt.tsx2
-rw-r--r--src/view/com/post-thread/PostThreadFollowBtn.tsx87
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx345
-rw-r--r--src/view/com/util/UserAvatar.tsx12
-rw-r--r--src/view/com/util/UserBanner.tsx6
-rw-r--r--src/view/com/util/ViewHeader.tsx2
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx16
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.tsx5
-rw-r--r--src/view/com/util/text/Text.tsx31
-rw-r--r--src/view/screens/Settings/DisableEmail2FADialog.tsx1
-rw-r--r--src/view/screens/Settings/ExportCarDialog.tsx1
-rw-r--r--src/view/screens/Storybook/Dialogs.tsx14
-rw-r--r--src/view/shell/Composer.ios.tsx77
-rw-r--r--src/view/shell/index.tsx37
-rw-r--r--web/index.html14
-rw-r--r--yarn.lock25
100 files changed, 2969 insertions, 1885 deletions
diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index 5f0f1ca78..c27262426 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -24,6 +24,20 @@
      *
      * THIS NEEDS TO BE DUPLICATED IN `bskyweb/templates/base.html`
      */
+    @font-face {
+      font-family: 'InterVariable';
+      src: url(/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf) format('truetype');
+      font-weight: 300 1000;
+      font-style: normal;
+      font-display: swap;
+    }
+    @font-face {
+      font-family: 'InterVariableItalic';
+      src: url(/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf) format('truetype');
+      font-weight: 300 1000;
+      font-style: italic;
+      font-display: swap;
+    }
     html {
       background-color: white;
       scrollbar-gutter: stable both-edges;
diff --git a/jest/jestSetup.js b/jest/jestSetup.js
index 4653490f3..5564d81f1 100644
--- a/jest/jestSetup.js
+++ b/jest/jestSetup.js
@@ -96,6 +96,11 @@ jest.mock('expo-modules-core', () => ({
         getIsReducedMotionEnabled: () => false,
       }
     }
+    if (moduleName === 'BottomSheet') {
+      return {
+        dismissAll: () => {},
+      }
+    }
   }),
   requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
     return () => null
diff --git a/modules/bottom-sheet/android/build.gradle b/modules/bottom-sheet/android/build.gradle
new file mode 100644
index 000000000..a1d423044
--- /dev/null
+++ b/modules/bottom-sheet/android/build.gradle
@@ -0,0 +1,49 @@
+apply plugin: 'com.android.library'
+
+group = 'expo.modules.bottomsheet'
+version = '0.1.0'
+
+def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+apply from: expoModulesCorePlugin
+applyKotlinExpoModulesCorePlugin()
+useCoreDependencies()
+useExpoPublishing()
+
+// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
+// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
+// Most of the time, you may like to manage the Android SDK versions yourself.
+def useManagedAndroidSdkVersions = false
+if (useManagedAndroidSdkVersions) {
+  useDefaultAndroidSdkVersions()
+} else {
+  buildscript {
+    // Simple helper that allows the root project to override versions declared by this library.
+    ext.safeExtGet = { prop, fallback ->
+      rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
+    }
+  }
+  project.android {
+    compileSdkVersion safeExtGet("compileSdkVersion", 34)
+    defaultConfig {
+      minSdkVersion safeExtGet("minSdkVersion", 21)
+      targetSdkVersion safeExtGet("targetSdkVersion", 34)
+    }
+  }
+}
+
+android {
+  namespace "expo.modules.bottomsheet"
+  defaultConfig {
+    versionCode 1
+    versionName "0.1.0"
+  }
+  lintOptions {
+    abortOnError false
+  }
+}
+
+dependencies {
+  implementation project(':expo-modules-core')
+  implementation 'com.google.android.material:material:1.12.0'
+  implementation "com.facebook.react:react-native:+"
+}
diff --git a/modules/bottom-sheet/android/src/main/AndroidManifest.xml b/modules/bottom-sheet/android/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..bdae66c8f
--- /dev/null
+++ b/modules/bottom-sheet/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+<manifest>
+</manifest>
diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt
new file mode 100644
index 000000000..057e6ed2e
--- /dev/null
+++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt
@@ -0,0 +1,53 @@
+package expo.modules.bottomsheet
+
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class BottomSheetModule : Module() {
+  override fun definition() =
+    ModuleDefinition {
+      Name("BottomSheet")
+
+      AsyncFunction("dismissAll") {
+        SheetManager.dismissAll()
+      }
+
+      View(BottomSheetView::class) {
+        Events(
+          arrayOf(
+            "onAttemptDismiss",
+            "onSnapPointChange",
+            "onStateChange",
+          ),
+        )
+
+        AsyncFunction("dismiss") { view: BottomSheetView ->
+          view.dismiss()
+        }
+
+        AsyncFunction("updateLayout") { view: BottomSheetView ->
+          view.updateLayout()
+        }
+
+        Prop("disableDrag") { view: BottomSheetView, prop: Boolean ->
+          view.disableDrag = prop
+        }
+
+        Prop("minHeight") { view: BottomSheetView, prop: Float ->
+          view.minHeight = prop
+        }
+
+        Prop("maxHeight") { view: BottomSheetView, prop: Float ->
+          view.maxHeight = prop
+        }
+
+        Prop("preventDismiss") { view: BottomSheetView, prop: Boolean ->
+          view.preventDismiss = prop
+        }
+
+        Prop("preventExpansion") { view: BottomSheetView, prop: Boolean ->
+          view.preventExpansion = prop
+        }
+      }
+    }
+}
diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt
new file mode 100644
index 000000000..a5a84ec3d
--- /dev/null
+++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt
@@ -0,0 +1,339 @@
+package expo.modules.bottomsheet
+
+import android.content.Context
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewStructure
+import android.view.accessibility.AccessibilityEvent
+import android.widget.FrameLayout
+import androidx.core.view.allViews
+import com.facebook.react.bridge.LifecycleEventListener
+import com.facebook.react.bridge.ReactContext
+import com.facebook.react.bridge.UiThreadUtil
+import com.facebook.react.uimanager.UIManagerHelper
+import com.facebook.react.uimanager.events.EventDispatcher
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.viewevent.EventDispatcher
+import expo.modules.kotlin.views.ExpoView
+import java.util.ArrayList
+
+class BottomSheetView(
+  context: Context,
+  appContext: AppContext,
+) : ExpoView(context, appContext),
+  LifecycleEventListener {
+  private var innerView: View? = null
+  private var dialog: BottomSheetDialog? = null
+
+  private lateinit var dialogRootViewGroup: DialogRootViewGroup
+  private var eventDispatcher: EventDispatcher? = null
+
+  private val screenHeight =
+    context.resources.displayMetrics.heightPixels
+      .toFloat()
+
+  private val onAttemptDismiss by EventDispatcher()
+  private val onSnapPointChange by EventDispatcher()
+  private val onStateChange by EventDispatcher()
+
+  // Props
+  var disableDrag = false
+    set (value) {
+      field = value
+      this.setDraggable(!value)
+    }
+
+  var preventDismiss = false
+    set(value) {
+      field = value
+      this.dialog?.setCancelable(!value)
+    }
+  var preventExpansion = false
+
+  var minHeight = 0f
+    set(value) {
+      field =
+        if (value < 0) {
+          0f
+        } else {
+          value
+        }
+    }
+
+  var maxHeight = this.screenHeight
+    set(value) {
+      field =
+        if (value > this.screenHeight) {
+          this.screenHeight.toFloat()
+        } else {
+          value
+        }
+    }
+
+  private var isOpen: Boolean = false
+    set(value) {
+      field = value
+      onStateChange(
+        mapOf(
+          "state" to if (value) "open" else "closed",
+        ),
+      )
+    }
+
+  private var isOpening: Boolean = false
+    set(value) {
+      field = value
+      if (value) {
+        onStateChange(
+          mapOf(
+            "state" to "opening",
+          ),
+        )
+      }
+    }
+
+  private var isClosing: Boolean = false
+    set(value) {
+      field = value
+      if (value) {
+        onStateChange(
+          mapOf(
+            "state" to "closing",
+          ),
+        )
+      }
+    }
+
+  private var selectedSnapPoint = 0
+    set(value) {
+      if (field == value) return
+
+      field = value
+      onSnapPointChange(
+        mapOf(
+          "snapPoint" to value,
+        ),
+      )
+    }
+
+  // Lifecycle
+
+  init {
+    (appContext.reactContext as? ReactContext)?.let {
+      it.addLifecycleEventListener(this)
+      this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
+
+      this.dialogRootViewGroup = DialogRootViewGroup(context)
+      this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
+    }
+    SheetManager.add(this)
+  }
+
+  override fun onLayout(
+    changed: Boolean,
+    l: Int,
+    t: Int,
+    r: Int,
+    b: Int,
+  ) {
+    this.present()
+  }
+
+  private fun destroy() {
+    this.isClosing = false
+    this.isOpen = false
+    this.dialog = null
+    this.innerView = null
+    SheetManager.remove(this)
+  }
+
+  // Presentation
+
+  private fun present() {
+    if (this.isOpen || this.isOpening || this.isClosing) return
+
+    val contentHeight = this.getContentHeight()
+
+    val dialog = BottomSheetDialog(context)
+    dialog.setContentView(dialogRootViewGroup)
+    dialog.setCancelable(!preventDismiss)
+    dialog.setOnShowListener {
+      val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
+      bottomSheet?.let {
+        // Let the outside view handle the background color on its own, the default for this is
+        // white and we don't want that.
+        it.setBackgroundColor(0)
+
+        val behavior = BottomSheetBehavior.from(it)
+
+        behavior.isFitToContents = true
+        behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight)
+        if (contentHeight > this.screenHeight) {
+          behavior.state = BottomSheetBehavior.STATE_EXPANDED
+          this.selectedSnapPoint = 2
+        } else {
+          behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
+          this.selectedSnapPoint = 1
+        }
+        behavior.skipCollapsed = true
+        behavior.isDraggable = true
+        behavior.isHideable = true
+
+        behavior.addBottomSheetCallback(
+          object : BottomSheetBehavior.BottomSheetCallback() {
+            override fun onStateChanged(
+              bottomSheet: View,
+              newState: Int,
+            ) {
+              when (newState) {
+                BottomSheetBehavior.STATE_EXPANDED -> {
+                  selectedSnapPoint = 2
+                }
+                BottomSheetBehavior.STATE_COLLAPSED -> {
+                  selectedSnapPoint = 1
+                }
+                BottomSheetBehavior.STATE_HALF_EXPANDED -> {
+                  selectedSnapPoint = 1
+                }
+                BottomSheetBehavior.STATE_HIDDEN -> {
+                  selectedSnapPoint = 0
+                }
+              }
+            }
+
+            override fun onSlide(
+              bottomSheet: View,
+              slideOffset: Float,
+            ) { }
+          },
+        )
+      }
+    }
+    dialog.setOnDismissListener {
+      this.isClosing = true
+      this.destroy()
+    }
+
+    this.isOpening = true
+    dialog.show()
+    this.dialog = dialog
+  }
+
+  fun updateLayout() {
+    val dialog = this.dialog ?: return
+    val contentHeight = this.getContentHeight()
+
+    val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
+    bottomSheet?.let {
+      val behavior = BottomSheetBehavior.from(it)
+
+      behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight)
+
+      if (contentHeight > this.screenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
+        behavior.state = BottomSheetBehavior.STATE_EXPANDED
+      } else if (contentHeight < this.screenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
+        behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
+      }
+    }
+  }
+
+  fun dismiss() {
+    this.dialog?.dismiss()
+  }
+
+  // Util
+
+  private fun getContentHeight(): Float {
+    val innerView = this.innerView ?: return 0f
+    var index = 0
+    innerView.allViews.forEach {
+      if (index == 1) {
+        return it.height.toFloat()
+      }
+      index++
+    }
+    return 0f
+  }
+
+  private fun getTargetHeight(): Float {
+    val contentHeight = this.getContentHeight()
+    val height =
+      if (contentHeight > maxHeight) {
+        maxHeight
+      } else if (contentHeight < minHeight) {
+        minHeight
+      } else {
+        contentHeight
+      }
+    return height
+  }
+
+  private fun clampRatio(ratio: Float): Float {
+    if (ratio < 0.01) {
+      return 0.01f
+    } else if (ratio > 0.99) {
+      return 0.99f
+    }
+    return ratio
+  }
+
+  private fun setDraggable(draggable: Boolean) {
+    val dialog = this.dialog ?: return
+    val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
+    bottomSheet?.let {
+      val behavior = BottomSheetBehavior.from(it)
+      behavior.isDraggable = draggable
+    }
+  }
+
+  override fun onHostResume() { }
+
+  override fun onHostPause() { }
+
+  override fun onHostDestroy() {
+    (appContext.reactContext as? ReactContext)?.let {
+      it.removeLifecycleEventListener(this)
+      this.destroy()
+    }
+  }
+
+  // View overrides to pass to DialogRootViewGroup instead
+
+  override fun dispatchProvideStructure(structure: ViewStructure?) {
+    dialogRootViewGroup.dispatchProvideStructure(structure)
+  }
+
+  override fun setId(id: Int) {
+    super.setId(id)
+    dialogRootViewGroup.id = id
+  }
+
+  override fun addView(
+    child: View?,
+    index: Int,
+  ) {
+    this.innerView = child
+    (child as ViewGroup).let {
+      dialogRootViewGroup.addView(child, index)
+    }
+  }
+
+  override fun removeView(view: View?) {
+    UiThreadUtil.assertOnUiThread()
+    if (view != null) {
+      dialogRootViewGroup.removeView(view)
+    }
+  }
+
+  override fun removeViewAt(index: Int) {
+    UiThreadUtil.assertOnUiThread()
+    val child = getChildAt(index)
+    dialogRootViewGroup.removeView(child)
+  }
+
+  override fun addChildrenForAccessibility(outChildren: ArrayList<View>?) { }
+
+  override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent?): Boolean = false
+}
diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt
new file mode 100644
index 000000000..c022924e9
--- /dev/null
+++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt
@@ -0,0 +1,171 @@
+package expo.modules.bottomsheet
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.view.MotionEvent
+import android.view.View
+import com.facebook.react.bridge.GuardedRunnable
+import com.facebook.react.config.ReactFeatureFlags
+import com.facebook.react.uimanager.JSPointerDispatcher
+import com.facebook.react.uimanager.JSTouchDispatcher
+import com.facebook.react.uimanager.RootView
+import com.facebook.react.uimanager.ThemedReactContext
+import com.facebook.react.uimanager.UIManagerModule
+import com.facebook.react.uimanager.events.EventDispatcher
+import com.facebook.react.views.view.ReactViewGroup
+
+// SEE https://github.com/facebook/react-native/blob/309cdea337101cfe2212cfb6abebf1e783e43282/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt#L378
+
+/**
+ * DialogRootViewGroup is the ViewGroup which contains all the children of a Modal. It gets all
+ * child information forwarded from [ReactModalHostView] and uses that to create children. It is
+ * also responsible for acting as a RootView and handling touch events. It does this the same way
+ * as ReactRootView.
+ *
+ * To get layout to work properly, we need to layout all the elements within the Modal as if they
+ * can fill the entire window. To do that, we need to explicitly set the styleWidth and
+ * styleHeight on the LayoutShadowNode to be the window size. This is done through the
+ * UIManagerModule, and will then cause the children to layout as if they can fill the window.
+ */
+class DialogRootViewGroup(
+  private val context: Context?,
+) : ReactViewGroup(context),
+  RootView {
+  private var hasAdjustedSize = false
+  private var viewWidth = 0
+  private var viewHeight = 0
+
+  private val jSTouchDispatcher = JSTouchDispatcher(this)
+  private var jSPointerDispatcher: JSPointerDispatcher? = null
+  private var sizeChangeListener: OnSizeChangeListener? = null
+
+  var eventDispatcher: EventDispatcher? = null
+
+  interface OnSizeChangeListener {
+    fun onSizeChange(
+      width: Int,
+      height: Int,
+    )
+  }
+
+  init {
+    if (ReactFeatureFlags.dispatchPointerEvents) {
+      jSPointerDispatcher = JSPointerDispatcher(this)
+    }
+  }
+
+  override fun onSizeChanged(
+    w: Int,
+    h: Int,
+    oldw: Int,
+    oldh: Int,
+  ) {
+    super.onSizeChanged(w, h, oldw, oldh)
+
+    viewWidth = w
+    viewHeight = h
+    updateFirstChildView()
+
+    sizeChangeListener?.onSizeChange(w, h)
+  }
+
+  fun setOnSizeChangeListener(listener: OnSizeChangeListener) {
+    sizeChangeListener = listener
+  }
+
+  private fun updateFirstChildView() {
+    if (childCount > 0) {
+      hasAdjustedSize = false
+      val viewTag = getChildAt(0).id
+      reactContext.runOnNativeModulesQueueThread(
+        object : GuardedRunnable(reactContext) {
+          override fun runGuarded() {
+            val uiManager: UIManagerModule =
+              reactContext
+                .reactApplicationContext
+                .getNativeModule(UIManagerModule::class.java) ?: return
+
+            uiManager.updateNodeSize(viewTag, viewWidth, viewHeight)
+          }
+        },
+      )
+    } else {
+      hasAdjustedSize = true
+    }
+  }
+
+  override fun addView(
+    child: View,
+    index: Int,
+    params: LayoutParams,
+  ) {
+    super.addView(child, index, params)
+    if (hasAdjustedSize) {
+      updateFirstChildView()
+    }
+  }
+
+  override fun handleException(t: Throwable) {
+    reactContext.reactApplicationContext.handleException(RuntimeException(t))
+  }
+
+  private val reactContext: ThemedReactContext
+    get() = context as ThemedReactContext
+
+  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
+    eventDispatcher?.let { jSTouchDispatcher.handleTouchEvent(event, it) }
+    jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true)
+    return super.onInterceptTouchEvent(event)
+  }
+
+  @SuppressLint("ClickableViewAccessibility")
+  override fun onTouchEvent(event: MotionEvent): Boolean {
+    eventDispatcher?.let { jSTouchDispatcher.handleTouchEvent(event, it) }
+    jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false)
+    super.onTouchEvent(event)
+
+    // In case when there is no children interested in handling touch event, we return true from
+    // the root view in order to receive subsequent events related to that gesture
+    return true
+  }
+
+  override fun onInterceptHoverEvent(event: MotionEvent): Boolean {
+    jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true)
+    return super.onHoverEvent(event)
+  }
+
+  override fun onHoverEvent(event: MotionEvent): Boolean {
+    jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false)
+    return super.onHoverEvent(event)
+  }
+
+  @Deprecated("Deprecated in Java")
+  override fun onChildStartedNativeGesture(ev: MotionEvent?) {
+    eventDispatcher?.let {
+      if (ev != null) {
+        jSTouchDispatcher.onChildStartedNativeGesture(ev, it)
+      }
+    }
+  }
+
+  override fun onChildStartedNativeGesture(
+    childView: View,
+    ev: MotionEvent,
+  ) {
+    eventDispatcher?.let { jSTouchDispatcher.onChildStartedNativeGesture(ev, it) }
+    jSPointerDispatcher?.onChildStartedNativeGesture(childView, ev, eventDispatcher)
+  }
+
+  override fun onChildEndedNativeGesture(
+    childView: View,
+    ev: MotionEvent,
+  ) {
+    eventDispatcher?.let { jSTouchDispatcher.onChildEndedNativeGesture(ev, it) }
+    jSPointerDispatcher?.onChildEndedNativeGesture()
+  }
+
+  override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
+    // No-op - override in order to still receive events to onInterceptTouchEvent
+    // even when some other view disallow that
+  }
+}
diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt
new file mode 100644
index 000000000..be7884998
--- /dev/null
+++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt
@@ -0,0 +1,28 @@
+package expo.modules.bottomsheet
+
+import java.lang.ref.WeakReference
+
+class SheetManager {
+  companion object {
+    private val sheets = mutableSetOf<WeakReference<BottomSheetView>>()
+
+    fun add(view: BottomSheetView) {
+      sheets.add(WeakReference(view))
+    }
+
+    fun remove(view: BottomSheetView) {
+      sheets.forEach {
+        if (it.get() == view) {
+          sheets.remove(it)
+          return
+        }
+      }
+    }
+
+    fun dismissAll() {
+      sheets.forEach {
+        it.get()?.dismiss()
+      }
+    }
+  }
+}
diff --git a/modules/bottom-sheet/expo-module.config.json b/modules/bottom-sheet/expo-module.config.json
new file mode 100644
index 000000000..81b5b078e
--- /dev/null
+++ b/modules/bottom-sheet/expo-module.config.json
@@ -0,0 +1,9 @@
+{
+  "platforms": ["ios", "android"],
+  "ios": {
+    "modules": ["BottomSheetModule"]
+  },
+  "android": {
+    "modules": ["expo.modules.bottomsheet.BottomSheetModule"]
+  }
+}
diff --git a/modules/bottom-sheet/index.ts b/modules/bottom-sheet/index.ts
new file mode 100644
index 000000000..1fe3dac0e
--- /dev/null
+++ b/modules/bottom-sheet/index.ts
@@ -0,0 +1,13 @@
+import {BottomSheet} from './src/BottomSheet'
+import {
+  BottomSheetSnapPoint,
+  BottomSheetState,
+  BottomSheetViewProps,
+} from './src/BottomSheet.types'
+
+export {
+  BottomSheet,
+  BottomSheetSnapPoint,
+  type BottomSheetState,
+  type BottomSheetViewProps,
+}
diff --git a/modules/bottom-sheet/ios/BottomSheet.podspec b/modules/bottom-sheet/ios/BottomSheet.podspec
new file mode 100644
index 000000000..a42356f61
--- /dev/null
+++ b/modules/bottom-sheet/ios/BottomSheet.podspec
@@ -0,0 +1,21 @@
+Pod::Spec.new do |s|
+  s.name           = 'BottomSheet'
+  s.version        = '1.0.0'
+  s.summary        = 'A bottom sheet for use in Bluesky'
+  s.description    = 'A bottom sheet for use in Bluesky'
+  s.author         = ''
+  s.homepage       = 'https://github.com/bluesky-social/social-app'
+  s.platforms      = { :ios => '15.0', :tvos => '15.0' }
+  s.source         = { git: '' }
+  s.static_framework = true
+
+  s.dependency 'ExpoModulesCore'
+
+  # Swift/Objective-C compatibility
+  s.pod_target_xcconfig = {
+    'DEFINES_MODULE' => 'YES',
+    'SWIFT_COMPILATION_MODE' => 'wholemodule'
+  }
+
+  s.source_files = "**/*.{h,m,swift}"
+end
diff --git a/modules/bottom-sheet/ios/BottomSheetModule.swift b/modules/bottom-sheet/ios/BottomSheetModule.swift
new file mode 100644
index 000000000..579608e75
--- /dev/null
+++ b/modules/bottom-sheet/ios/BottomSheetModule.swift
@@ -0,0 +1,47 @@
+import ExpoModulesCore
+
+public class BottomSheetModule: Module {
+  public func definition() -> ModuleDefinition {
+    Name("BottomSheet")
+
+    AsyncFunction("dismissAll") {
+      SheetManager.shared.dismissAll()
+    }
+
+    View(SheetView.self) {
+      Events([
+        "onAttemptDismiss",
+        "onSnapPointChange",
+        "onStateChange"
+      ])
+
+      AsyncFunction("dismiss") { (view: SheetView) in
+        view.dismiss()
+      }
+
+      AsyncFunction("updateLayout") { (view: SheetView) in
+        view.updateLayout()
+      }
+
+      Prop("cornerRadius") { (view: SheetView, prop: Float) in
+        view.cornerRadius = CGFloat(prop)
+      }
+
+      Prop("minHeight") { (view: SheetView, prop: Double) in
+        view.minHeight = prop
+      }
+
+      Prop("maxHeight") { (view: SheetView, prop: Double) in
+        view.maxHeight = prop
+      }
+
+      Prop("preventDismiss") { (view: SheetView, prop: Bool) in
+        view.preventDismiss = prop
+      }
+
+      Prop("preventExpansion") { (view: SheetView, prop: Bool) in
+        view.preventExpansion = prop
+      }
+    }
+  }
+}
diff --git a/modules/bottom-sheet/ios/SheetManager.swift b/modules/bottom-sheet/ios/SheetManager.swift
new file mode 100644
index 000000000..e4e843bea
--- /dev/null
+++ b/modules/bottom-sheet/ios/SheetManager.swift
@@ -0,0 +1,28 @@
+//
+//  SheetManager.swift
+//  Pods
+//
+//  Created by Hailey on 10/1/24.
+//
+
+import ExpoModulesCore
+
+class SheetManager {
+  static let shared = SheetManager()
+
+  private var sheetViews = NSHashTable<SheetView>(options: .weakMemory)
+
+  func add(_ view: SheetView) {
+    sheetViews.add(view)
+  }
+
+  func remove(_ view: SheetView) {
+    sheetViews.remove(view)
+  }
+
+  func dismissAll() {
+    sheetViews.allObjects.forEach { sheetView in
+      sheetView.dismiss()
+    }
+  }
+}
diff --git a/modules/bottom-sheet/ios/SheetView.swift b/modules/bottom-sheet/ios/SheetView.swift
new file mode 100644
index 000000000..cf2019c6a
--- /dev/null
+++ b/modules/bottom-sheet/ios/SheetView.swift
@@ -0,0 +1,189 @@
+import ExpoModulesCore
+import UIKit
+
+class SheetView: ExpoView, UISheetPresentationControllerDelegate {
+  // Views
+  private var sheetVc: SheetViewController?
+  private var innerView: UIView?
+  private var touchHandler: RCTTouchHandler?
+
+  // Events
+  private let onAttemptDismiss = EventDispatcher()
+  private let onSnapPointChange = EventDispatcher()
+  private let onStateChange = EventDispatcher()
+
+  // Open event firing
+  private var isOpen: Bool = false {
+    didSet {
+      onStateChange([
+        "state": isOpen ? "open" : "closed"
+      ])
+    }
+  }
+
+  // React view props
+  var preventDismiss = false
+  var preventExpansion = false
+  var cornerRadius: CGFloat?
+  var minHeight = 0.0
+  var maxHeight: CGFloat! {
+    didSet {
+      let screenHeight = Util.getScreenHeight() ?? 0
+      if maxHeight > screenHeight {
+        maxHeight = screenHeight
+      }
+    }
+  }
+
+  private var isOpening = false {
+    didSet {
+      if isOpening {
+        onStateChange([
+          "state": "opening"
+        ])
+      }
+    }
+  }
+  private var isClosing = false {
+    didSet {
+      if isClosing {
+        onStateChange([
+          "state": "closing"
+        ])
+      }
+    }
+  }
+  private var selectedDetentIdentifier: UISheetPresentationController.Detent.Identifier? {
+    didSet {
+      if selectedDetentIdentifier == .large {
+        onSnapPointChange([
+          "snapPoint": 2
+        ])
+      } else {
+        onSnapPointChange([
+          "snapPoint": 1
+        ])
+      }
+    }
+  }
+
+  // MARK: - Lifecycle
+
+  required init (appContext: AppContext? = nil) {
+    super.init(appContext: appContext)
+    self.maxHeight = Util.getScreenHeight()
+    self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge)
+    SheetManager.shared.add(self)
+  }
+
+  deinit {
+    self.destroy()
+  }
+
+  // We don't want this view to actually get added to the tree, so we'll simply store it for adding
+  // to the SheetViewController
+  override func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
+    self.touchHandler?.attach(to: subview)
+    self.innerView = subview
+  }
+
+  // We'll grab the content height from here so we know the initial detent to set
+  override func layoutSubviews() {
+    super.layoutSubviews()
+
+    guard let innerView = self.innerView else {
+      return
+    }
+
+    if innerView.subviews.count != 1 {
+      return
+    }
+
+    self.present()
+  }
+
+  private func destroy() {
+    self.isClosing = false
+    self.isOpen = false
+    self.sheetVc = nil
+    self.touchHandler?.detach(from: self.innerView)
+    self.touchHandler = nil
+    self.innerView = nil
+    SheetManager.shared.remove(self)
+  }
+
+  // MARK: - Presentation
+
+  func present() {
+    guard !self.isOpen,
+          !self.isOpening,
+          !self.isClosing,
+          let innerView = self.innerView,
+          let contentHeight = innerView.subviews.first?.frame.height,
+          let rvc = self.reactViewController() else {
+      return
+    }
+
+    let sheetVc = SheetViewController()
+    sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion)
+    if let sheet = sheetVc.sheetPresentationController {
+      sheet.delegate = self
+      sheet.preferredCornerRadius = self.cornerRadius
+      self.selectedDetentIdentifier = sheet.selectedDetentIdentifier
+    }
+    sheetVc.view.addSubview(innerView)
+
+    self.sheetVc = sheetVc
+    self.isOpening = true
+
+    rvc.present(sheetVc, animated: true) { [weak self] in
+      self?.isOpening = false
+      self?.isOpen = true
+    }
+  }
+
+  func updateLayout() {
+    if let contentHeight = self.innerView?.subviews.first?.frame.size.height {
+      self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight),
+                               preventExpansion: self.preventExpansion)
+      self.selectedDetentIdentifier = self.sheetVc?.getCurrentDetentIdentifier()
+    }
+  }
+
+  func dismiss() {
+    self.isClosing = true
+    self.sheetVc?.dismiss(animated: true) { [weak self] in
+      self?.destroy()
+    }
+  }
+
+  // MARK: - Utils
+
+  private func clampHeight(_ height: CGFloat) -> CGFloat {
+    if height < self.minHeight {
+      return self.minHeight
+    } else if height > self.maxHeight {
+      return self.maxHeight
+    }
+    return height
+  }
+
+  // MARK: - UISheetPresentationControllerDelegate
+
+  func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
+    self.onAttemptDismiss()
+    return !self.preventDismiss
+  }
+
+  func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
+    self.isClosing = true
+  }
+
+  func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
+    self.destroy()
+  }
+
+  func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) {
+    self.selectedDetentIdentifier = sheetPresentationController.selectedDetentIdentifier
+  }
+}
diff --git a/modules/bottom-sheet/ios/SheetViewController.swift b/modules/bottom-sheet/ios/SheetViewController.swift
new file mode 100644
index 000000000..56473b21c
--- /dev/null
+++ b/modules/bottom-sheet/ios/SheetViewController.swift
@@ -0,0 +1,76 @@
+//
+//  SheetViewController.swift
+//  Pods
+//
+//  Created by Hailey on 9/30/24.
+//
+
+import Foundation
+import UIKit
+
+class SheetViewController: UIViewController {
+  init() {
+    super.init(nibName: nil, bundle: nil)
+
+    self.modalPresentationStyle = .formSheet
+    self.isModalInPresentation = false
+
+    if let sheet = self.sheetPresentationController {
+      sheet.prefersGrabberVisible = false
+    }
+  }
+
+  func setDetents(contentHeight: CGFloat, preventExpansion: Bool) {
+    guard let sheet = self.sheetPresentationController,
+          let screenHeight = Util.getScreenHeight()
+    else {
+      return
+    }
+
+    if contentHeight > screenHeight - 100 {
+      sheet.detents = [
+        .large()
+      ]
+      sheet.selectedDetentIdentifier = .large
+    } else {
+      if #available(iOS 16.0, *) {
+        sheet.detents = [
+          .custom { _ in
+            return contentHeight
+          }
+        ]
+      } else {
+        sheet.detents = [
+          .medium()
+        ]
+      }
+
+      if !preventExpansion {
+        sheet.detents.append(.large())
+      }
+      sheet.selectedDetentIdentifier = .medium
+    }
+  }
+
+  func updateDetents(contentHeight: CGFloat, preventExpansion: Bool) {
+    if let sheet = self.sheetPresentationController {
+      sheet.animateChanges {
+        self.setDetents(contentHeight: contentHeight, preventExpansion: preventExpansion)
+        if #available(iOS 16.0, *) {
+          sheet.invalidateDetents()
+        }
+      }
+    }
+  }
+  
+  func getCurrentDetentIdentifier() -> UISheetPresentationController.Detent.Identifier? {
+    guard let sheet = self.sheetPresentationController else {
+      return nil
+    }
+    return sheet.selectedDetentIdentifier
+  }
+
+  required init?(coder: NSCoder) {
+    fatalError("init(coder:) has not been implemented")
+  }
+}
diff --git a/modules/bottom-sheet/ios/Util.swift b/modules/bottom-sheet/ios/Util.swift
new file mode 100644
index 000000000..c654596a7
--- /dev/null
+++ b/modules/bottom-sheet/ios/Util.swift
@@ -0,0 +1,18 @@
+//
+//  Util.swift
+//  Pods
+//
+//  Created by Hailey on 10/2/24.
+//
+
+class Util {
+  static func getScreenHeight() -> CGFloat? {
+    if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+     let window = windowScene.windows.first {
+      let safeAreaInsets = window.safeAreaInsets
+      let fullScreenHeight = UIScreen.main.bounds.height
+      return fullScreenHeight - (safeAreaInsets.top + safeAreaInsets.bottom)
+    }
+    return nil
+  }
+}
diff --git a/modules/bottom-sheet/src/BottomSheet.tsx b/modules/bottom-sheet/src/BottomSheet.tsx
new file mode 100644
index 000000000..9e7d0c209
--- /dev/null
+++ b/modules/bottom-sheet/src/BottomSheet.tsx
@@ -0,0 +1,100 @@
+import * as React from 'react'
+import {
+  Dimensions,
+  NativeSyntheticEvent,
+  Platform,
+  StyleProp,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
+
+import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types'
+
+const screenHeight = Dimensions.get('screen').height
+
+const NativeView: React.ComponentType<
+  BottomSheetViewProps & {
+    ref: React.RefObject<any>
+    style: StyleProp<ViewStyle>
+  }
+> = requireNativeViewManager('BottomSheet')
+
+const NativeModule = requireNativeModule('BottomSheet')
+
+export class BottomSheet extends React.Component<
+  BottomSheetViewProps,
+  {
+    open: boolean
+  }
+> {
+  ref = React.createRef<any>()
+
+  constructor(props: BottomSheetViewProps) {
+    super(props)
+    this.state = {
+      open: false,
+    }
+  }
+
+  present() {
+    this.setState({open: true})
+  }
+
+  dismiss() {
+    this.ref.current?.dismiss()
+  }
+
+  private onStateChange = (
+    event: NativeSyntheticEvent<{state: BottomSheetState}>,
+  ) => {
+    const {state} = event.nativeEvent
+    const isOpen = state !== 'closed'
+    this.setState({open: isOpen})
+    this.props.onStateChange?.(event)
+  }
+
+  private updateLayout = () => {
+    this.ref.current?.updateLayout()
+  }
+
+  static dismissAll = async () => {
+    await NativeModule.dismissAll()
+  }
+
+  render() {
+    const {children, backgroundColor, ...rest} = this.props
+    const cornerRadius = rest.cornerRadius ?? 0
+
+    if (!this.state.open) {
+      return null
+    }
+
+    return (
+      <NativeView
+        {...rest}
+        onStateChange={this.onStateChange}
+        ref={this.ref}
+        style={{
+          position: 'absolute',
+          height: screenHeight,
+          width: '100%',
+        }}
+        containerBackgroundColor={backgroundColor}>
+        <View
+          style={[
+            {
+              flex: 1,
+              backgroundColor,
+            },
+            Platform.OS === 'android' && {
+              borderTopLeftRadius: cornerRadius,
+              borderTopRightRadius: cornerRadius,
+            },
+          ]}>
+          <View onLayout={this.updateLayout}>{children}</View>
+        </View>
+      </NativeView>
+    )
+  }
+}
diff --git a/modules/bottom-sheet/src/BottomSheet.types.ts b/modules/bottom-sheet/src/BottomSheet.types.ts
new file mode 100644
index 000000000..150932d42
--- /dev/null
+++ b/modules/bottom-sheet/src/BottomSheet.types.ts
@@ -0,0 +1,35 @@
+import React from 'react'
+import {ColorValue, NativeSyntheticEvent} from 'react-native'
+
+export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening'
+
+export enum BottomSheetSnapPoint {
+  Hidden,
+  Partial,
+  Full,
+}
+
+export type BottomSheetAttemptDismissEvent = NativeSyntheticEvent<object>
+export type BottomSheetSnapPointChangeEvent = NativeSyntheticEvent<{
+  snapPoint: BottomSheetSnapPoint
+}>
+export type BottomSheetStateChangeEvent = NativeSyntheticEvent<{
+  state: BottomSheetState
+}>
+
+export interface BottomSheetViewProps {
+  children: React.ReactNode
+  cornerRadius?: number
+  preventDismiss?: boolean
+  preventExpansion?: boolean
+  backgroundColor?: ColorValue
+  containerBackgroundColor?: ColorValue
+  disableDrag?: boolean
+
+  minHeight?: number
+  maxHeight?: number
+
+  onAttemptDismiss?: (event: BottomSheetAttemptDismissEvent) => void
+  onSnapPointChange?: (event: BottomSheetSnapPointChangeEvent) => void
+  onStateChange?: (event: BottomSheetStateChangeEvent) => void
+}
diff --git a/modules/bottom-sheet/src/BottomSheet.web.tsx b/modules/bottom-sheet/src/BottomSheet.web.tsx
new file mode 100644
index 000000000..4573604eb
--- /dev/null
+++ b/modules/bottom-sheet/src/BottomSheet.web.tsx
@@ -0,0 +1,5 @@
+import {BottomSheetViewProps} from './BottomSheet.types'
+
+export function BottomSheet(_: BottomSheetViewProps) {
+  throw new Error('BottomSheetView is not available on web')
+}
diff --git a/package.json b/package.json
index 9f66545db..828f11cb7 100644
--- a/package.json
+++ b/package.json
@@ -137,7 +137,6 @@
     "expo-sharing": "^12.0.1",
     "expo-splash-screen": "~0.27.4",
     "expo-status-bar": "~1.12.1",
-    "expo-system-ui": "~3.0.4",
     "expo-task-manager": "~11.8.1",
     "expo-updates": "~0.25.14",
     "expo-web-browser": "~13.0.3",
@@ -171,11 +170,11 @@
     "react-native-compressor": "^1.8.24",
     "react-native-date-picker": "^4.4.2",
     "react-native-drawer-layout": "^4.0.0-alpha.3",
-    "react-native-gesture-handler": "~2.16.2",
+    "react-native-gesture-handler": "2.20.0",
     "react-native-get-random-values": "~1.11.0",
     "react-native-image-crop-picker": "0.41.2",
     "react-native-ios-context-menu": "^1.15.3",
-    "react-native-keyboard-controller": "^1.12.1",
+    "react-native-keyboard-controller": "^1.14.0",
     "react-native-mmkv": "^2.12.2",
     "react-native-pager-view": "6.2.3",
     "react-native-picker-select": "^9.1.3",
diff --git a/patches/react-native+0.74.1.patch b/patches/react-native+0.74.1.patch
index aee3da1ec..ea6161e2f 100644
--- a/patches/react-native+0.74.1.patch
+++ b/patches/react-native+0.74.1.patch
@@ -38,37 +38,65 @@ index b0d71dc..41b9a0e 100644
 
  - (void)reactBlur
 diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
-index e9b330f..1ecdf0a 100644
+index e9b330f..ec5f58c 100644
 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
 +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
-@@ -16,4 +16,6 @@
+@@ -15,5 +15,8 @@
+ @property (nonatomic, copy) NSString *title;
  @property (nonatomic, copy) RCTDirectEventBlock onRefresh;
  @property (nonatomic, weak) UIScrollView *scrollView;
-
-+- (void)forwarderBeginRefreshing;
++@property (nonatomic, copy) UIColor *customTintColor;
 +
++- (void)forwarderBeginRefreshing;
+
  @end
 diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
-index b09e653..f93cb46 100644
+index b09e653..288e60c 100644
 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
 +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
-@@ -198,9 +198,53 @@ - (void)refreshControlValueChanged
-   [self setCurrentRefreshingState:super.refreshing];
-   _refreshingProgrammatically = NO;
+@@ -22,6 +22,7 @@ @implementation RCTRefreshControl {
+   NSString *_title;
+   UIColor *_titleColor;
+   CGFloat _progressViewOffset;
++  UIColor *_customTintColor;
+ }
 
-+  if (@available(iOS 17.4, *)) {
-+    if (_currentRefreshingState) {
-+      UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
-+      [feedbackGenerator prepare];
-+      [feedbackGenerator impactOccurred];
-+    }
-+  }
+ - (instancetype)init
+@@ -56,6 +57,12 @@ - (void)layoutSubviews
+   _isInitialRender = false;
+ }
+
++- (void)didMoveToSuperview
++{
++  [super didMoveToSuperview];
++  [self setTintColor:_customTintColor];
++}
 +
-   if (_onRefresh) {
-     _onRefresh(nil);
+ - (void)beginRefreshingProgrammatically
+ {
+   UInt64 beginRefreshingTimestamp = _currentRefreshingStateTimestamp;
+@@ -203,4 +210,58 @@ - (void)refreshControlValueChanged
    }
  }
 
++- (void)setCustomTintColor:(UIColor *)customTintColor
++{
++  _customTintColor = customTintColor;
++  [self setTintColor:customTintColor];
++}
++
++// Fix for https://github.com/facebook/react-native/issues/43388
++// A bug in iOS 17.4 causes the haptic to not play when refreshing if the tintColor
++// is set before the refresh control gets added to the scrollview. We'll call this
++// function whenever the superview changes. We'll also call it if the value of customTintColor
++// changes.
++- (void)setTintColor:(UIColor *)tintColor
++{
++  if ([self.superview isKindOfClass:[UIScrollView class]] && self.tintColor != tintColor) {
++    [super setTintColor:tintColor];
++  }
++}
++
 +/*
 + This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
 + libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
@@ -106,6 +134,24 @@ index b09e653..f93cb46 100644
 +}
 +
  @end
+diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
+index 40aaf9c..1c60164 100644
+--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
++++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
+@@ -22,11 +22,12 @@ - (UIView *)view
+
+ RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
+ RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
+-RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor)
+ RCT_EXPORT_VIEW_PROPERTY(title, NSString)
+ RCT_EXPORT_VIEW_PROPERTY(titleColor, UIColor)
+ RCT_EXPORT_VIEW_PROPERTY(progressViewOffset, CGFloat)
+
++RCT_REMAP_VIEW_PROPERTY(tintColor, customTintColor, UIColor)
++
+ RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing)
+ {
+   [self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
 diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java
 index 5f5e1ab..aac00b6 100644
 --- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java
diff --git a/src/App.native.tsx b/src/App.native.tsx
index e2fcd6d2e..c6334379f 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -1,6 +1,6 @@
 import 'react-native-url-polyfill/auto'
-import 'lib/sentry' // must be near top
-import 'view/icons'
+import '#/lib/sentry' // must be near top
+import '#/view/icons'
 
 import React, {useEffect, useState} from 'react'
 import {GestureHandlerRootView} from 'react-native-gesture-handler'
diff --git a/src/alf/fonts.ts b/src/alf/fonts.ts
index 54fe7a34e..b46faed1c 100644
--- a/src/alf/fonts.ts
+++ b/src/alf/fonts.ts
@@ -70,6 +70,8 @@ export function applyFonts(
  * IMPORTANT: This is unused. Expo statically extracts these fonts.
  *
  * All used fonts MUST be configured here. Unused fonts can be commented out.
+ *
+ * This is used for both web fonts and native fonts.
  */
 export function DO_NOT_USE() {
   return useFonts({
diff --git a/src/alf/util/useColorModeTheme.ts b/src/alf/util/useColorModeTheme.ts
index 12840c706..561a504b2 100644
--- a/src/alf/util/useColorModeTheme.ts
+++ b/src/alf/util/useColorModeTheme.ts
@@ -1,9 +1,8 @@
 import React from 'react'
 import {ColorSchemeName, useColorScheme} from 'react-native'
-import * as SystemUI from 'expo-system-ui'
 
-import {isWeb} from 'platform/detection'
-import {useThemePrefs} from 'state/shell'
+import {isWeb} from '#/platform/detection'
+import {useThemePrefs} from '#/state/shell'
 import {dark, dim, light} from '#/alf/themes'
 import {ThemeName} from '#/alf/types'
 
@@ -12,7 +11,6 @@ export function useColorModeTheme(): ThemeName {
 
   React.useLayoutEffect(() => {
     updateDocument(theme)
-    SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
   }, [theme])
 
   return theme
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 1c14b48c7..4acb4f1dc 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -87,6 +87,7 @@ export type ButtonProps = Pick<
     style?: StyleProp<ViewStyle>
     hoverStyle?: StyleProp<ViewStyle>
     children: NonTextElements | ((context: ButtonContext) => NonTextElements)
+    PressableComponent?: React.ComponentType<PressableProps>
   }
 
 export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
@@ -114,6 +115,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
       disabled = false,
       style,
       hoverStyle: hoverStyleProp,
+      PressableComponent = Pressable,
       ...rest
     },
     ref,
@@ -449,10 +451,11 @@ export const Button = React.forwardRef<View, ButtonProps>(
     const flattenedBaseStyles = flatten([baseStyles, style])
 
     return (
-      <Pressable
+      <PressableComponent
         role="button"
         accessibilityHint={undefined} // optional
         {...rest}
+        // @ts-ignore - this will always be a pressable
         ref={ref}
         aria-label={label}
         aria-pressed={state.pressed}
@@ -500,7 +503,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
         <Context.Provider value={context}>
           {typeof children === 'function' ? children(context) : children}
         </Context.Provider>
-      </Pressable>
+      </PressableComponent>
     )
   },
 )
diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts
index 859f8edd7..b479bc7f0 100644
--- a/src/components/Dialog/context.ts
+++ b/src/components/Dialog/context.ts
@@ -6,9 +6,14 @@ import {
   DialogControlRefProps,
   DialogOuterProps,
 } from '#/components/Dialog/types'
+import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
 
 export const Context = React.createContext<DialogContextProps>({
   close: () => {},
+  isNativeDialog: false,
+  nativeSnapPoint: BottomSheetSnapPoint.Hidden,
+  disableDrag: false,
+  setDisableDrag: () => {},
 })
 
 export function useDialogContext() {
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index d5d92048a..49b5e10b2 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -1,86 +1,48 @@
 import React, {useImperativeHandle} from 'react'
 import {
-  Dimensions,
-  Keyboard,
+  NativeScrollEvent,
+  NativeSyntheticEvent,
   Pressable,
+  ScrollView,
   StyleProp,
+  TextInput,
   View,
   ViewStyle,
 } from 'react-native'
-import Animated, {useAnimatedStyle} from 'react-native-reanimated'
+import {
+  KeyboardAwareScrollView,
+  useKeyboardHandler,
+} from 'react-native-keyboard-controller'
+import {runOnJS} from 'react-native-reanimated'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import BottomSheet, {
-  BottomSheetBackdropProps,
-  BottomSheetFlatList,
-  BottomSheetFlatListMethods,
-  BottomSheetScrollView,
-  BottomSheetScrollViewMethods,
-  BottomSheetTextInput,
-  BottomSheetView,
-  useBottomSheet,
-  WINDOW_HEIGHT,
-} from '@discord/bottom-sheet/src'
-import {BottomSheetFlatListProps} from '@discord/bottom-sheet/src/components/bottomSheetScrollable/types'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
 import {logger} from '#/logger'
+import {isAndroid, isIOS} from '#/platform/detection'
+import {useA11y} from '#/state/a11y'
 import {useDialogStateControlContext} from '#/state/dialogs'
-import {atoms as a, flatten, useTheme} from '#/alf'
-import {Context} from '#/components/Dialog/context'
+import {List, ListMethods, ListProps} from '#/view/com/util/List'
+import {atoms as a, useTheme} from '#/alf'
+import {Context, useDialogContext} from '#/components/Dialog/context'
 import {
   DialogControlProps,
   DialogInnerProps,
   DialogOuterProps,
 } from '#/components/Dialog/types'
 import {createInput} from '#/components/forms/TextField'
-import {FullWindowOverlay} from '#/components/FullWindowOverlay'
-import {Portal} from '#/components/Portal'
+import {Portal as DefaultPortal} from '#/components/Portal'
+import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
+import {
+  BottomSheetSnapPointChangeEvent,
+  BottomSheetStateChangeEvent,
+} from '../../../modules/bottom-sheet/src/BottomSheet.types'
 
 export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
 export * from '#/components/Dialog/types'
 export * from '#/components/Dialog/utils'
 // @ts-ignore
-export const Input = createInput(BottomSheetTextInput)
-
-function Backdrop(props: BottomSheetBackdropProps) {
-  const t = useTheme()
-  const bottomSheet = useBottomSheet()
-
-  const animatedStyle = useAnimatedStyle(() => {
-    const opacity =
-      (Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000
-
-    return {
-      opacity: Math.min(Math.max(opacity, 0), 0.55),
-    }
-  })
-
-  const onPress = React.useCallback(() => {
-    bottomSheet.close()
-  }, [bottomSheet])
-
-  return (
-    <Animated.View
-      style={[
-        t.atoms.bg_contrast_300,
-        {
-          top: 0,
-          left: 0,
-          right: 0,
-          bottom: 0,
-          position: 'absolute',
-        },
-        animatedStyle,
-      ]}>
-      <Pressable
-        accessibilityRole="button"
-        accessibilityLabel="Dialog backdrop"
-        accessibilityHint="Press the backdrop to close the dialog"
-        style={{flex: 1}}
-        onPress={onPress}
-      />
-    </Animated.View>
-  )
-}
+export const Input = createInput(TextInput)
 
 export function Outer({
   children,
@@ -88,24 +50,22 @@ export function Outer({
   onClose,
   nativeOptions,
   testID,
+  Portal = DefaultPortal,
 }: React.PropsWithChildren<DialogOuterProps>) {
   const t = useTheme()
-  const sheet = React.useRef<BottomSheet>(null)
-  const sheetOptions = nativeOptions?.sheet || {}
-  const hasSnapPoints = !!sheetOptions.snapPoints
-  const insets = useSafeAreaInsets()
+  const ref = React.useRef<BottomSheet>(null)
   const closeCallbacks = React.useRef<(() => void)[]>([])
-  const {setDialogIsOpen} = useDialogStateControlContext()
+  const {setDialogIsOpen, setFullyExpandedCount} =
+    useDialogStateControlContext()
 
-  /*
-   * Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
-   */
-  const [openIndex, setOpenIndex] = React.useState(-1)
+  const prevSnapPoint = React.useRef<BottomSheetSnapPoint>(
+    BottomSheetSnapPoint.Hidden,
+  )
 
-  /*
-   * `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open.
-   */
-  const isOpen = openIndex > -1
+  const [disableDrag, setDisableDrag] = React.useState(false)
+  const [snapPoint, setSnapPoint] = React.useState<BottomSheetSnapPoint>(
+    BottomSheetSnapPoint.Partial,
+  )
 
   const callQueuedCallbacks = React.useCallback(() => {
     for (const cb of closeCallbacks.current) {
@@ -119,25 +79,19 @@ export function Outer({
     closeCallbacks.current = []
   }, [])
 
-  const open = React.useCallback<DialogControlProps['open']>(
-    ({index} = {}) => {
-      // Run any leftover callbacks that might have been queued up before calling `.open()`
-      callQueuedCallbacks()
-
-      setDialogIsOpen(control.id, true)
-      // can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
-      setOpenIndex(index || 0)
-      sheet.current?.snapToIndex(index || 0)
-    },
-    [setDialogIsOpen, control.id, callQueuedCallbacks],
-  )
+  const open = React.useCallback<DialogControlProps['open']>(() => {
+    // Run any leftover callbacks that might have been queued up before calling `.open()`
+    callQueuedCallbacks()
+    setDialogIsOpen(control.id, true)
+    ref.current?.present()
+  }, [setDialogIsOpen, control.id, callQueuedCallbacks])
 
   // This is the function that we call when we want to dismiss the dialog.
   const close = React.useCallback<DialogControlProps['close']>(cb => {
     if (typeof cb === 'function') {
       closeCallbacks.current.push(cb)
     }
-    sheet.current?.close()
+    ref.current?.dismiss()
   }, [])
 
   // This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to
@@ -146,12 +100,39 @@ export function Outer({
     // This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this
     // tells us that we need to toggle the accessibility overlay setting
     setDialogIsOpen(control.id, false)
-    setOpenIndex(-1)
-
     callQueuedCallbacks()
     onClose?.()
   }, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen])
 
+  const onSnapPointChange = (e: BottomSheetSnapPointChangeEvent) => {
+    const {snapPoint} = e.nativeEvent
+    setSnapPoint(snapPoint)
+
+    if (
+      snapPoint === BottomSheetSnapPoint.Full &&
+      prevSnapPoint.current !== BottomSheetSnapPoint.Full
+    ) {
+      setFullyExpandedCount(c => c + 1)
+    } else if (
+      snapPoint !== BottomSheetSnapPoint.Full &&
+      prevSnapPoint.current === BottomSheetSnapPoint.Full
+    ) {
+      setFullyExpandedCount(c => c - 1)
+    }
+    prevSnapPoint.current = snapPoint
+  }
+
+  const onStateChange = (e: BottomSheetStateChangeEvent) => {
+    if (e.nativeEvent.state === 'closed') {
+      onCloseAnimationComplete()
+
+      if (prevSnapPoint.current === BottomSheetSnapPoint.Full) {
+        setFullyExpandedCount(c => c - 1)
+      }
+      prevSnapPoint.current = BottomSheetSnapPoint.Hidden
+    }
+  }
+
   useImperativeHandle(
     control.ref,
     () => ({
@@ -161,159 +142,144 @@ export function Outer({
     [open, close],
   )
 
-  React.useEffect(() => {
-    return () => {
-      setDialogIsOpen(control.id, false)
-    }
-  }, [control.id, setDialogIsOpen])
-
-  const context = React.useMemo(() => ({close}), [close])
+  const context = React.useMemo(
+    () => ({
+      close,
+      isNativeDialog: true,
+      nativeSnapPoint: snapPoint,
+      disableDrag,
+      setDisableDrag,
+    }),
+    [close, snapPoint, disableDrag, setDisableDrag],
+  )
 
   return (
-    isOpen && (
-      <Portal>
-        <FullWindowOverlay>
-          <View
-            // iOS
-            accessibilityViewIsModal
-            // Android
-            importantForAccessibility="yes"
-            style={[a.absolute, a.inset_0]}
-            testID={testID}
-            onTouchMove={() => Keyboard.dismiss()}>
-            <BottomSheet
-              enableDynamicSizing={!hasSnapPoints}
-              enablePanDownToClose
-              keyboardBehavior="interactive"
-              android_keyboardInputMode="adjustResize"
-              keyboardBlurBehavior="restore"
-              topInset={insets.top}
-              {...sheetOptions}
-              snapPoints={sheetOptions.snapPoints || ['100%']}
-              ref={sheet}
-              index={openIndex}
-              backgroundStyle={{backgroundColor: 'transparent'}}
-              backdropComponent={Backdrop}
-              handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
-              handleStyle={{display: 'none'}}
-              onClose={onCloseAnimationComplete}>
-              <Context.Provider value={context}>
-                <View
-                  style={[
-                    a.absolute,
-                    a.inset_0,
-                    t.atoms.bg,
-                    {
-                      borderTopLeftRadius: 40,
-                      borderTopRightRadius: 40,
-                      height: Dimensions.get('window').height * 2,
-                    },
-                  ]}
-                />
-                {children}
-              </Context.Provider>
-            </BottomSheet>
-          </View>
-        </FullWindowOverlay>
-      </Portal>
-    )
+    <Portal>
+      <Context.Provider value={context}>
+        <BottomSheet
+          ref={ref}
+          cornerRadius={20}
+          backgroundColor={t.atoms.bg.backgroundColor}
+          {...nativeOptions}
+          onSnapPointChange={onSnapPointChange}
+          onStateChange={onStateChange}
+          disableDrag={disableDrag}>
+          <View testID={testID}>{children}</View>
+        </BottomSheet>
+      </Context.Provider>
+    </Portal>
   )
 }
 
 export function Inner({children, style}: DialogInnerProps) {
   const insets = useSafeAreaInsets()
   return (
-    <BottomSheetView
+    <View
       style={[
-        a.py_xl,
+        a.pt_2xl,
         a.px_xl,
         {
-          paddingTop: 40,
-          borderTopLeftRadius: 40,
-          borderTopRightRadius: 40,
-          paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
+          paddingBottom: insets.bottom + insets.top,
         },
-        flatten(style),
+        style,
       ]}>
       {children}
-    </BottomSheetView>
+    </View>
   )
 }
 
-export const ScrollableInner = React.forwardRef<
-  BottomSheetScrollViewMethods,
-  DialogInnerProps
->(function ScrollableInner({children, style}, ref) {
-  const insets = useSafeAreaInsets()
-  return (
-    <BottomSheetScrollView
-      keyboardShouldPersistTaps="handled"
-      style={[
-        a.flex_1, // main diff is this
-        a.p_xl,
-        a.h_full,
-        {
-          paddingTop: 40,
-          borderTopLeftRadius: 40,
-          borderTopRightRadius: 40,
-        },
-        style,
-      ]}
-      contentContainerStyle={a.pb_4xl}
-      ref={ref}>
-      {children}
-      <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
-    </BottomSheetScrollView>
-  )
-})
+export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
+  function ScrollableInner({children, style, ...props}, ref) {
+    const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
+    const insets = useSafeAreaInsets()
+    const [keyboardHeight, setKeyboardHeight] = React.useState(0)
+    useKeyboardHandler({
+      onEnd: e => {
+        'worklet'
+        runOnJS(setKeyboardHeight)(e.height)
+      },
+    })
+
+    const basePading =
+      (isIOS ? 30 : 50) + (isIOS ? keyboardHeight / 4 : keyboardHeight)
+    const fullPaddingBase = insets.bottom + insets.top + basePading
+    const fullPadding = isIOS ? fullPaddingBase : fullPaddingBase + 50
+
+    const paddingBottom =
+      nativeSnapPoint === BottomSheetSnapPoint.Full ? fullPadding : basePading
+
+    const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
+      const {contentOffset} = e.nativeEvent
+      if (contentOffset.y > 0 && !disableDrag) {
+        setDisableDrag(true)
+      } else if (contentOffset.y <= 1 && disableDrag) {
+        setDisableDrag(false)
+      }
+    }
+
+    return (
+      <KeyboardAwareScrollView
+        style={[style]}
+        contentContainerStyle={[a.pt_2xl, a.px_xl, {paddingBottom}]}
+        ref={ref}
+        {...props}
+        bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
+        bottomOffset={30}
+        scrollEventThrottle={50}
+        onScroll={isAndroid ? onScroll : undefined}>
+        {children}
+      </KeyboardAwareScrollView>
+    )
+  },
+)
 
 export const InnerFlatList = React.forwardRef<
-  BottomSheetFlatListMethods,
-  BottomSheetFlatListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
->(function InnerFlatList({style, contentContainerStyle, ...props}, ref) {
+  ListMethods,
+  ListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
+>(function InnerFlatList({style, ...props}, ref) {
   const insets = useSafeAreaInsets()
-
+  const {nativeSnapPoint} = useDialogContext()
   return (
-    <BottomSheetFlatList
+    <List
       keyboardShouldPersistTaps="handled"
-      contentContainerStyle={[a.pb_4xl, flatten(contentContainerStyle)]}
+      bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
       ListFooterComponent={
         <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
       }
       ref={ref}
       {...props}
-      style={[
-        a.flex_1,
-        a.p_xl,
-        a.pt_0,
-        a.h_full,
-        {
-          marginTop: 40,
-        },
-        flatten(style),
-      ]}
+      style={[style]}
     />
   )
 })
 
 export function Handle() {
   const t = useTheme()
+  const {_} = useLingui()
+  const {screenReaderEnabled} = useA11y()
+  const {close} = useDialogContext()
 
   return (
-    <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
-      <View
-        style={[
-          a.rounded_sm,
-          {
-            top: a.pt_lg.paddingTop,
-            width: 35,
-            height: 4,
-            alignSelf: 'center',
-            backgroundColor: t.palette.contrast_900,
-            opacity: 0.5,
-          },
-        ]}
-      />
+    <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 20}]}>
+      <Pressable
+        accessible={screenReaderEnabled}
+        onPress={() => close()}
+        accessibilityLabel={_(msg`Dismiss`)}
+        accessibilityHint={_(msg`Double tap to close the dialog`)}>
+        <View
+          style={[
+            a.rounded_sm,
+            {
+              top: 10,
+              width: 35,
+              height: 5,
+              alignSelf: 'center',
+              backgroundColor: t.palette.contrast_975,
+              opacity: 0.5,
+            },
+          ]}
+        />
+      </Pressable>
     </View>
   )
 }
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index bf20bd295..7b9cfb693 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -103,6 +103,10 @@ export function Outer({
   const context = React.useMemo(
     () => ({
       close,
+      isNativeDialog: false,
+      nativeSnapPoint: 0,
+      disableDrag: false,
+      setDisableDrag: () => {},
     }),
     [close],
   )
@@ -229,10 +233,6 @@ export const InnerFlatList = React.forwardRef<
   )
 })
 
-export function Handle() {
-  return null
-}
-
 export function Close() {
   const {_} = useLingui()
   const {close} = React.useContext(Context)
@@ -258,3 +258,7 @@ export function Close() {
     </View>
   )
 }
+
+export function Handle() {
+  return null
+}
diff --git a/src/components/Dialog/sheet-wrapper.ts b/src/components/Dialog/sheet-wrapper.ts
new file mode 100644
index 000000000..37c663383
--- /dev/null
+++ b/src/components/Dialog/sheet-wrapper.ts
@@ -0,0 +1,20 @@
+import {useCallback} from 'react'
+
+import {useDialogStateControlContext} from '#/state/dialogs'
+
+/**
+ * If we're calling a system API like the image picker that opens a sheet
+ * wrap it in this function to make sure the status bar is the correct color.
+ */
+export function useSheetWrapper() {
+  const {setFullyExpandedCount} = useDialogStateControlContext()
+  return useCallback(
+    async <T>(promise: Promise<T>): Promise<T> => {
+      setFullyExpandedCount(c => c + 1)
+      const res = await promise
+      setFullyExpandedCount(c => c - 1)
+      return res
+    },
+    [setFullyExpandedCount],
+  )
+}
diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts
index 1ddab02ee..caa787535 100644
--- a/src/components/Dialog/types.ts
+++ b/src/components/Dialog/types.ts
@@ -4,9 +4,11 @@ import type {
   GestureResponderEvent,
   ScrollViewProps,
 } from 'react-native'
-import {BottomSheetProps} from '@discord/bottom-sheet/src'
 
 import {ViewStyleProp} from '#/alf'
+import {PortalComponent} from '#/components/Portal'
+import {BottomSheetViewProps} from '../../../modules/bottom-sheet'
+import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
 
 type A11yProps = Required<AccessibilityProps>
 
@@ -37,6 +39,10 @@ export type DialogControlProps = DialogControlRefProps & {
 
 export type DialogContextProps = {
   close: DialogControlProps['close']
+  isNativeDialog: boolean
+  nativeSnapPoint: BottomSheetSnapPoint
+  disableDrag: boolean
+  setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
 }
 
 export type DialogControlOpenOptions = {
@@ -52,11 +58,10 @@ export type DialogControlOpenOptions = {
 export type DialogOuterProps = {
   control: DialogControlProps
   onClose?: () => void
-  nativeOptions?: {
-    sheet?: Omit<BottomSheetProps, 'children'>
-  }
+  nativeOptions?: Omit<BottomSheetViewProps, 'children'>
   webOptions?: {}
   testID?: string
+  Portal?: PortalComponent
 }
 
 type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
diff --git a/src/components/KeyboardControllerPadding.android.tsx b/src/components/KeyboardControllerPadding.android.tsx
deleted file mode 100644
index 92ef1b0b0..000000000
--- a/src/components/KeyboardControllerPadding.android.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react'
-import {useKeyboardHandler} from 'react-native-keyboard-controller'
-import Animated, {
-  useAnimatedStyle,
-  useSharedValue,
-} from 'react-native-reanimated'
-
-export function KeyboardControllerPadding({maxHeight}: {maxHeight?: number}) {
-  const keyboardHeight = useSharedValue(0)
-
-  useKeyboardHandler(
-    {
-      onMove: e => {
-        'worklet'
-
-        if (maxHeight && e.height > maxHeight) {
-          keyboardHeight.value = maxHeight
-        } else {
-          keyboardHeight.value = e.height
-        }
-      },
-    },
-    [maxHeight],
-  )
-
-  const animatedStyle = useAnimatedStyle(() => ({
-    height: keyboardHeight.value,
-  }))
-
-  return <Animated.View style={animatedStyle} />
-}
diff --git a/src/components/KeyboardControllerPadding.tsx b/src/components/KeyboardControllerPadding.tsx
deleted file mode 100644
index f3163d87c..000000000
--- a/src/components/KeyboardControllerPadding.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export function KeyboardControllerPadding({
-  maxHeight: _,
-}: {
-  maxHeight?: number
-}) {
-  return null
-}
diff --git a/src/components/LikesDialog.tsx b/src/components/LikesDialog.tsx
index 94a3f27e2..4c68596f7 100644
--- a/src/components/LikesDialog.tsx
+++ b/src/components/LikesDialog.tsx
@@ -1,20 +1,19 @@
-import React, {useMemo, useCallback} from 'react'
+import React, {useCallback, useMemo} from 'react'
 import {ActivityIndicator, FlatList, View} from 'react-native'
+import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
 
-import {useResolveUriQuery} from '#/state/queries/resolve-uri'
-import {useLikedByQuery} from '#/state/queries/post-liked-by'
 import {cleanError} from '#/lib/strings/errors'
 import {logger} from '#/logger'
-
+import {useLikedByQuery} from '#/state/queries/post-liked-by'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
+import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
 import {atoms as a, useTheme} from '#/alf'
-import {Text} from '#/components/Typography'
 import * as Dialog from '#/components/Dialog'
-import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
-import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
 import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
 
 interface LikesDialogProps {
   control: Dialog.DialogOuterProps['control']
@@ -25,7 +24,6 @@ export function LikesDialog(props: LikesDialogProps) {
   return (
     <Dialog.Outer control={props.control}>
       <Dialog.Handle />
-
       <LikesDialogInner {...props} />
     </Dialog.Outer>
   )
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index c80b9f370..447833a23 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -103,17 +103,17 @@ export function useLink({
           linkRequiresWarning(href, displayText),
       )
 
-      if (requiresWarning) {
+      if (isWeb) {
         e.preventDefault()
+      }
 
+      if (requiresWarning) {
         openModal({
           name: 'link-warning',
           text: displayText,
           href: href,
         })
       } else {
-        e.preventDefault()
-
         if (isExternal) {
           openLink(href)
         } else {
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index a0a21a50f..a22f43cf8 100644
--- a/src/components/Menu/index.tsx
+++ b/src/components/Menu/index.tsx
@@ -4,7 +4,7 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import flattenReactChildren from 'react-keyed-flatten-children'
 
-import {isNative} from 'platform/detection'
+import {isNative} from '#/platform/detection'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
@@ -82,19 +82,21 @@ export function Outer({
   style?: StyleProp<ViewStyle>
 }>) {
   const context = React.useContext(Context)
+  const {_} = useLingui()
 
   return (
-    <Dialog.Outer control={context.control}>
+    <Dialog.Outer
+      control={context.control}
+      nativeOptions={{preventExpansion: true}}>
       <Dialog.Handle />
-
       {/* Re-wrap with context since Dialogs are portal-ed to root */}
       <Context.Provider value={context}>
-        <Dialog.ScrollableInner label="Menu TODO">
+        <Dialog.ScrollableInner label={_(msg`Menu`)} style={[a.pt_sm]}>
           <View style={[a.gap_lg]}>
             {children}
             {isNative && showCancel && <Cancel />}
+            <View style={[{height: a.pb_lg.paddingBottom}]} />
           </View>
-          <View style={{height: a.gap_lg.gap}} />
         </Dialog.ScrollableInner>
       </Context.Provider>
     </Dialog.Outer>
@@ -116,15 +118,14 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
       {...rest}
       accessibilityHint=""
       accessibilityLabel={label}
-      onPress={e => {
-        onPress(e)
-
+      onFocus={onFocus}
+      onBlur={onBlur}
+      onPress={async e => {
+        await onPress(e)
         if (!e.defaultPrevented) {
           control?.close()
         }
       }}
-      onFocus={onFocus}
-      onBlur={onBlur}
       onPressIn={e => {
         onPressIn()
         rest.onPressIn?.(e)
diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx
index 1a523a839..0e3520658 100644
--- a/src/components/NewskieDialog.tsx
+++ b/src/components/NewskieDialog.tsx
@@ -5,12 +5,12 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {differenceInSeconds} from 'date-fns'
 
+import {HITSLOP_10} from '#/lib/constants'
 import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {isNative} from '#/platform/detection'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {HITSLOP_10} from 'lib/constants'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {useSession} from 'state/session'
+import {useSession} from '#/state/session'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx
index 03b397b2b..7441df005 100644
--- a/src/components/Portal.tsx
+++ b/src/components/Portal.tsx
@@ -12,6 +12,8 @@ type ComponentMap = {
   [id: string]: Component
 }
 
+export type PortalComponent = ({children}: {children?: React.ReactNode}) => null
+
 export function createPortalGroup() {
   const Context = React.createContext<ContextType>({
     outlet: null,
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 8765cdee3..fc6919af8 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -4,8 +4,9 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
-import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
+import {Button, ButtonColor, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
+import {PortalComponent} from '#/components/Portal'
 import {Text} from '#/components/Typography'
 
 export {
@@ -25,9 +26,11 @@ export function Outer({
   children,
   control,
   testID,
+  Portal,
 }: React.PropsWithChildren<{
   control: Dialog.DialogControlProps
   testID?: string
+  Portal?: PortalComponent
 }>) {
   const {gtMobile} = useBreakpoints()
   const titleId = React.useId()
@@ -39,10 +42,9 @@ export function Outer({
   )
 
   return (
-    <Dialog.Outer control={control} testID={testID}>
+    <Dialog.Outer control={control} testID={testID} Portal={Portal}>
+      <Dialog.Handle />
       <Context.Provider value={context}>
-        <Dialog.Handle />
-
         <Dialog.ScrollableInner
           accessibilityLabelledBy={titleId}
           accessibilityDescribedBy={descriptionId}
@@ -141,7 +143,7 @@ export function Action({
    * Note: The dialog will close automatically when the action is pressed, you
    * should NOT close the dialog as a side effect of this method.
    */
-  onPress: ButtonProps['onPress']
+  onPress: (e: GestureResponderEvent) => void
   color?: ButtonColor
   /**
    * Optional i18n string. If undefined, it will default to "Confirm".
@@ -181,6 +183,7 @@ export function Basic({
   onConfirm,
   confirmButtonColor,
   showCancel = true,
+  Portal,
 }: React.PropsWithChildren<{
   control: Dialog.DialogOuterProps['control']
   title: string
@@ -194,12 +197,13 @@ export function Basic({
    * Note: The dialog will close automatically when the action is pressed, you
    * should NOT close the dialog as a side effect of this method.
    */
-  onConfirm: ButtonProps['onPress']
+  onConfirm: (e: GestureResponderEvent) => void
   confirmButtonColor?: ButtonColor
   showCancel?: boolean
+  Portal?: PortalComponent
 }>) {
   return (
-    <Outer control={control} testID="confirmModal">
+    <Outer control={control} testID="confirmModal" Portal={Portal}>
       <TitleText>{title}</TitleText>
       <DescriptionText>{description}</DescriptionText>
       <Actions>
diff --git a/src/components/ReportDialog/SelectLabelerView.tsx b/src/components/ReportDialog/SelectLabelerView.tsx
index f7a8139ea..039bbf123 100644
--- a/src/components/ReportDialog/SelectLabelerView.tsx
+++ b/src/components/ReportDialog/SelectLabelerView.tsx
@@ -4,7 +4,6 @@ import {AppBskyLabelerDefs} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
 import {getLabelingServiceTitle} from '#/lib/moderation'
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import {Button, useButtonContext} from '#/components/Button'
diff --git a/src/components/ReportDialog/SubmitView.tsx b/src/components/ReportDialog/SubmitView.tsx
index e323d1504..ef4a9b7fb 100644
--- a/src/components/ReportDialog/SubmitView.tsx
+++ b/src/components/ReportDialog/SubmitView.tsx
@@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react'
 
 import {getLabelingServiceTitle} from '#/lib/moderation'
 import {ReportOption} from '#/lib/moderation/useReportOptions'
+import {isAndroid} from '#/platform/detection'
 import {useAgent} from '#/state/session'
 import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
 import * as Toast from '#/view/com/util/Toast'
@@ -225,6 +226,8 @@ export function SubmitView({
           {submitting && <ButtonIcon icon={Loader} />}
         </Button>
       </View>
+      {/* Maybe fix this later -h */}
+      {isAndroid ? <View style={{height: 300}} /> : null}
     </View>
   )
 }
diff --git a/src/components/ReportDialog/index.tsx b/src/components/ReportDialog/index.tsx
index c87d32f9e..5bf8aa5b4 100644
--- a/src/components/ReportDialog/index.tsx
+++ b/src/components/ReportDialog/index.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
 import {Pressable, View} from 'react-native'
+import {ScrollView} from 'react-native-gesture-handler'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -8,12 +9,10 @@ import {useMyLabelersQuery} from '#/state/queries/preferences'
 export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
 
 import {AppBskyLabelerDefs} from '@atproto/api'
-import {BottomSheetScrollViewMethods} from '@discord/bottom-sheet/src'
 
 import {atoms as a} from '#/alf'
 import * as Dialog from '#/components/Dialog'
 import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
-import {useOnKeyboardDidShow} from '#/components/hooks/useOnKeyboard'
 import {Loader} from '#/components/Loader'
 import {Text} from '#/components/Typography'
 import {SelectLabelerView} from './SelectLabelerView'
@@ -25,7 +24,6 @@ export function ReportDialog(props: ReportDialogProps) {
   return (
     <Dialog.Outer control={props.control}>
       <Dialog.Handle />
-
       <ReportDialogInner {...props} />
     </Dialog.Outer>
   )
@@ -40,10 +38,7 @@ function ReportDialogInner(props: ReportDialogProps) {
   } = useMyLabelersQuery()
   const isLoading = useDelayedLoading(500, isLabelerLoading)
 
-  const ref = React.useRef<BottomSheetScrollViewMethods>(null)
-  useOnKeyboardDidShow(() => {
-    ref.current?.scrollToEnd({animated: true})
-  })
+  const ref = React.useRef<ScrollView>(null)
 
   return (
     <Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}>
diff --git a/src/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx
index b2af8ff73..2feea0973 100644
--- a/src/components/StarterPack/QrCodeDialog.tsx
+++ b/src/components/StarterPack/QrCodeDialog.tsx
@@ -149,7 +149,6 @@ export function QrCodeDialog({
 
   return (
     <Dialog.Outer control={control}>
-      <Dialog.Handle />
       <Dialog.ScrollableInner
         label={_(msg`Create a QR code for a starter pack`)}>
         <View style={[a.flex_1, a.align_center, a.gap_5xl]}>
diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx
index 9851b0856..997c6479c 100644
--- a/src/components/StarterPack/ShareDialog.tsx
+++ b/src/components/StarterPack/ShareDialog.tsx
@@ -6,14 +6,14 @@ import {AppBskyGraphDefs} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {saveImageToMediaLibrary} from '#/lib/media/manip'
+import {shareUrl} from '#/lib/sharing'
+import {logEvent} from '#/lib/statsig/statsig'
+import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
 import {logger} from '#/logger'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {saveImageToMediaLibrary} from 'lib/media/manip'
-import {shareUrl} from 'lib/sharing'
-import {logEvent} from 'lib/statsig/statsig'
-import {getStarterPackOgCard} from 'lib/strings/starter-pack'
-import {isNative, isWeb} from 'platform/detection'
-import * as Toast from 'view/com/util/Toast'
+import {isNative, isWeb} from '#/platform/detection'
+import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import {DialogControlProps} from '#/components/Dialog'
@@ -32,6 +32,7 @@ interface Props {
 export function ShareDialog(props: Props) {
   return (
     <Dialog.Outer control={props.control}>
+      <Dialog.Handle />
       <ShareDialogInner {...props} />
     </Dialog.Outer>
   )
@@ -84,7 +85,6 @@ function ShareDialogInner({
 
   return (
     <>
-      <Dialog.Handle />
       <Dialog.ScrollableInner label={_(msg`Share link dialog`)}>
         {!imageLoaded || !link ? (
           <View style={[a.p_xl, a.align_center]}>
diff --git a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx
index f7b0aba34..1e9f1c52d 100644
--- a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx
+++ b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx
@@ -3,13 +3,13 @@ import type {ListRenderItemInfo} from 'react-native'
 import {View} from 'react-native'
 import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
 import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
-import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
-import {isWeb} from 'platform/detection'
-import {useSession} from 'state/session'
+import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
+import {isWeb} from '#/platform/detection'
+import {useSession} from '#/state/session'
+import {ListMethods} from '#/view/com/util/List'
 import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
 import {atoms as a, native, useTheme, web} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
@@ -45,7 +45,7 @@ export function WizardEditListDialog({
   const {currentAccount} = useSession()
   const initialNumToRender = useInitialNumToRender()
 
-  const listRef = useRef<BottomSheetFlatListMethods>(null)
+  const listRef = useRef<ListMethods>(null)
 
   const getData = () => {
     if (state.currentStep === 'Feeds') return state.feeds
@@ -76,10 +76,7 @@ export function WizardEditListDialog({
     )
 
   return (
-    <Dialog.Outer
-      control={control}
-      testID="newChatDialog"
-      nativeOptions={{sheet: {snapPoints: ['95%']}}}>
+    <Dialog.Outer control={control} testID="newChatDialog">
       <Dialog.Handle />
       <Dialog.InnerFlatList
         ref={listRef}
@@ -89,6 +86,7 @@ export function WizardEditListDialog({
         ListHeaderComponent={
           <View
             style={[
+              native(a.pt_4xl),
               a.flex_row,
               a.justify_between,
               a.border_b,
@@ -103,13 +101,7 @@ export function WizardEditListDialog({
                       height: 48,
                     },
                   ]
-                : [
-                    a.pb_sm,
-                    a.align_end,
-                    {
-                      height: 68,
-                    },
-                  ],
+                : [a.pb_sm, a.align_end],
             ]}>
             <View style={{width: 60}} />
             <Text style={[a.font_bold, a.text_xl]}>
@@ -143,8 +135,6 @@ export function WizardEditListDialog({
             paddingHorizontal: 0,
             marginTop: 0,
             paddingTop: 0,
-            borderTopLeftRadius: 40,
-            borderTopRightRadius: 40,
           }),
         ]}
         webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx
index 2c6a0b674..917624a03 100644
--- a/src/components/TagMenu/index.tsx
+++ b/src/components/TagMenu/index.tsx
@@ -85,7 +85,6 @@ export function TagMenu({
 
       <Dialog.Outer control={control}>
         <Dialog.Handle />
-
         <Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
           {isPreferencesLoading ? (
             <View style={[a.w_full, a.align_center]}>
diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx
index 501e23872..19eba35fb 100644
--- a/src/components/Typography.tsx
+++ b/src/components/Typography.tsx
@@ -53,11 +53,14 @@ export function childIsString(
   )
 }
 
-export function renderChildrenWithEmoji(children: StringChild) {
+export function renderChildrenWithEmoji(
+  children: StringChild,
+  props: Omit<TextProps, 'children'> = {},
+) {
   const normalized = Array.isArray(children) ? children : [children]
 
   return (
-    <UITextView>
+    <UITextView {...props}>
       {normalized.map(child => {
         if (typeof child !== 'string') return child
 
@@ -68,10 +71,12 @@ export function renderChildrenWithEmoji(children: StringChild) {
         }
 
         return child.split(EMOJI).map((stringPart, index) => (
-          <UITextView key={index}>
+          <UITextView key={index} {...props}>
             {stringPart}
             {emojis[index] ? (
-              <UITextView style={{color: 'black', fontFamily: 'System'}}>
+              <UITextView
+                {...props}
+                style={[props?.style, {color: 'black', fontFamily: 'System'}]}>
                 {emojis[index]}
               </UITextView>
             ) : null}
@@ -163,15 +168,17 @@ export function Text({
     }
   }
 
+  const shared = {
+    uiTextView: true,
+    selectable,
+    style: s,
+    dataSet: Object.assign({tooltip: title}, dataSet || {}),
+    ...rest,
+  }
+
   return (
-    <UITextView
-      selectable={selectable}
-      uiTextView
-      style={s}
-      {...rest}
-      // @ts-ignore
-      dataSet={Object.assign({tooltip: title}, dataSet || {})}>
-      {isIOS && emoji ? renderChildrenWithEmoji(children) : children}
+    <UITextView {...shared}>
+      {isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children}
     </UITextView>
   )
 }
diff --git a/src/components/dialogs/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx
index 08608f9d8..81d0c6740 100644
--- a/src/components/dialogs/BirthDateSettings.tsx
+++ b/src/components/dialogs/BirthDateSettings.tsx
@@ -31,7 +31,6 @@ export function BirthDateSettingsDialog({
   return (
     <Dialog.Outer control={control}>
       <Dialog.Handle />
-
       <Dialog.ScrollableInner label={_(msg`My Birthday`)}>
         <View style={[a.gap_sm, a.pb_lg]}>
           <Text style={[a.text_2xl, a.font_bold]}>
diff --git a/src/components/dialogs/EmbedConsent.tsx b/src/components/dialogs/EmbedConsent.tsx
index 765b8adc7..824155d8b 100644
--- a/src/components/dialogs/EmbedConsent.tsx
+++ b/src/components/dialogs/EmbedConsent.tsx
@@ -50,7 +50,6 @@ export function EmbedConsentDialog({
   return (
     <Dialog.Outer control={control}>
       <Dialog.Handle />
-
       <Dialog.ScrollableInner
         label={_(msg`External Media`)}
         style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
diff --git a/src/components/dialogs/GifSelect.ios.tsx b/src/components/dialogs/GifSelect.ios.tsx
deleted file mode 100644
index 2f867e865..000000000
--- a/src/components/dialogs/GifSelect.ios.tsx
+++ /dev/null
@@ -1,255 +0,0 @@
-import React, {
-  useCallback,
-  useImperativeHandle,
-  useMemo,
-  useRef,
-  useState,
-} from 'react'
-import {Modal, ScrollView, TextInput, View} from 'react-native'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {cleanError} from '#/lib/strings/errors'
-import {
-  Gif,
-  useFeaturedGifsQuery,
-  useGifSearchQuery,
-} from '#/state/queries/tenor'
-import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
-import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
-import {FlatList_INTERNAL} from '#/view/com/util/Views'
-import {atoms as a, useBreakpoints, useTheme} from '#/alf'
-import * as TextField from '#/components/forms/TextField'
-import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
-import {Button, ButtonText} from '../Button'
-import {Handle} from '../Dialog'
-import {useThrottledValue} from '../hooks/useThrottledValue'
-import {ListFooter, ListMaybePlaceholder} from '../Lists'
-import {GifPreview} from './GifSelect.shared'
-
-export function GifSelectDialog({
-  controlRef,
-  onClose,
-  onSelectGif: onSelectGifProp,
-}: {
-  controlRef: React.RefObject<{open: () => void}>
-  onClose: () => void
-  onSelectGif: (gif: Gif) => void
-}) {
-  const t = useTheme()
-  const [open, setOpen] = useState(false)
-
-  useImperativeHandle(controlRef, () => ({
-    open: () => setOpen(true),
-  }))
-
-  const close = useCallback(() => {
-    setOpen(false)
-    onClose()
-  }, [onClose])
-
-  const onSelectGif = useCallback(
-    (gif: Gif) => {
-      onSelectGifProp(gif)
-      close()
-    },
-    [onSelectGifProp, close],
-  )
-
-  const renderErrorBoundary = useCallback(
-    (error: any) => <ModalError details={String(error)} close={close} />,
-    [close],
-  )
-
-  return (
-    <Modal
-      visible={open}
-      animationType="slide"
-      presentationStyle="formSheet"
-      onRequestClose={close}
-      aria-modal
-      accessibilityViewIsModal>
-      <View style={[a.flex_1, t.atoms.bg]}>
-        <Handle />
-        <ErrorBoundary renderError={renderErrorBoundary}>
-          <GifList onSelectGif={onSelectGif} close={close} />
-        </ErrorBoundary>
-      </View>
-    </Modal>
-  )
-}
-
-function GifList({
-  onSelectGif,
-}: {
-  close: () => void
-  onSelectGif: (gif: Gif) => void
-}) {
-  const {_} = useLingui()
-  const t = useTheme()
-  const {gtMobile} = useBreakpoints()
-  const textInputRef = useRef<TextInput>(null)
-  const listRef = useRef<FlatList_INTERNAL>(null)
-  const [undeferredSearch, setSearch] = useState('')
-  const search = useThrottledValue(undeferredSearch, 500)
-
-  const isSearching = search.length > 0
-
-  const trendingQuery = useFeaturedGifsQuery()
-  const searchQuery = useGifSearchQuery(search)
-
-  const {
-    data,
-    fetchNextPage,
-    isFetchingNextPage,
-    hasNextPage,
-    error,
-    isLoading,
-    isError,
-    refetch,
-  } = isSearching ? searchQuery : trendingQuery
-
-  const flattenedData = useMemo(() => {
-    return data?.pages.flatMap(page => page.results) || []
-  }, [data])
-
-  const renderItem = useCallback(
-    ({item}: {item: Gif}) => {
-      return <GifPreview gif={item} onSelectGif={onSelectGif} />
-    },
-    [onSelectGif],
-  )
-
-  const onEndReached = React.useCallback(() => {
-    if (isFetchingNextPage || !hasNextPage || error) return
-    fetchNextPage()
-  }, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
-
-  const hasData = flattenedData.length > 0
-
-  const onGoBack = useCallback(() => {
-    if (isSearching) {
-      // clear the input and reset the state
-      textInputRef.current?.clear()
-      setSearch('')
-    } else {
-      close()
-    }
-  }, [isSearching])
-
-  const listHeader = useMemo(() => {
-    return (
-      <View style={[a.relative, a.mb_lg, a.pt_4xl, a.flex_row, a.align_center]}>
-        {/* cover top corners */}
-        <View
-          style={[
-            a.absolute,
-            a.inset_0,
-            {
-              borderBottomLeftRadius: 8,
-              borderBottomRightRadius: 8,
-            },
-            t.atoms.bg,
-          ]}
-        />
-
-        <TextField.Root>
-          <TextField.Icon icon={Search} />
-          <TextField.Input
-            label={_(msg`Search GIFs`)}
-            placeholder={_(msg`Search Tenor`)}
-            onChangeText={text => {
-              setSearch(text)
-              listRef.current?.scrollToOffset({offset: 0, animated: false})
-            }}
-            returnKeyType="search"
-            clearButtonMode="while-editing"
-            inputRef={textInputRef}
-            maxLength={50}
-          />
-        </TextField.Root>
-      </View>
-    )
-  }, [t.atoms.bg, _])
-
-  return (
-    <FlatList_INTERNAL
-      ref={listRef}
-      key={gtMobile ? '3 cols' : '2 cols'}
-      data={flattenedData}
-      renderItem={renderItem}
-      numColumns={gtMobile ? 3 : 2}
-      columnWrapperStyle={a.gap_sm}
-      contentContainerStyle={a.px_lg}
-      ListHeaderComponent={
-        <>
-          {listHeader}
-          {!hasData && (
-            <ListMaybePlaceholder
-              isLoading={isLoading}
-              isError={isError}
-              onRetry={refetch}
-              onGoBack={onGoBack}
-              emptyType="results"
-              sideBorders={false}
-              topBorder={false}
-              errorTitle={_(msg`Failed to load GIFs`)}
-              errorMessage={_(msg`There was an issue connecting to Tenor.`)}
-              emptyMessage={
-                isSearching
-                  ? _(msg`No search results found for "${search}".`)
-                  : _(
-                      msg`No featured GIFs found. There may be an issue with Tenor.`,
-                    )
-              }
-            />
-          )}
-        </>
-      }
-      stickyHeaderIndices={[0]}
-      onEndReached={onEndReached}
-      onEndReachedThreshold={4}
-      keyExtractor={(item: Gif) => item.id}
-      keyboardDismissMode="on-drag"
-      ListFooterComponent={
-        hasData ? (
-          <ListFooter
-            isFetchingNextPage={isFetchingNextPage}
-            error={cleanError(error)}
-            onRetry={fetchNextPage}
-            style={{borderTopWidth: 0}}
-          />
-        ) : null
-      }
-    />
-  )
-}
-
-function ModalError({details, close}: {details?: string; close: () => void}) {
-  const {_} = useLingui()
-
-  return (
-    <ScrollView
-      style={[a.flex_1, a.gap_md]}
-      centerContent
-      contentContainerStyle={a.px_lg}>
-      <ErrorScreen
-        title={_(msg`Oh no!`)}
-        message={_(
-          msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
-        )}
-        details={details}
-      />
-      <Button
-        label={_(msg`Close dialog`)}
-        onPress={close}
-        color="primary"
-        size="large"
-        variant="solid">
-        <ButtonText>
-          <Trans>Close</Trans>
-        </ButtonText>
-      </Button>
-    </ScrollView>
-  )
-}
diff --git a/src/components/dialogs/GifSelect.shared.tsx b/src/components/dialogs/GifSelect.shared.tsx
deleted file mode 100644
index 90b2abaa8..000000000
--- a/src/components/dialogs/GifSelect.shared.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import React, {useCallback} from 'react'
-import {Image} from 'expo-image'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {logEvent} from '#/lib/statsig/statsig'
-import {Gif} from '#/state/queries/tenor'
-import {atoms as a, useBreakpoints, useTheme} from '#/alf'
-import {Button} from '../Button'
-
-export function GifPreview({
-  gif,
-  onSelectGif,
-}: {
-  gif: Gif
-  onSelectGif: (gif: Gif) => void
-}) {
-  const {gtTablet} = useBreakpoints()
-  const {_} = useLingui()
-  const t = useTheme()
-
-  const onPress = useCallback(() => {
-    logEvent('composer:gif:select', {})
-    onSelectGif(gif)
-  }, [onSelectGif, gif])
-
-  return (
-    <Button
-      label={_(msg`Select GIF "${gif.title}"`)}
-      style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
-      onPress={onPress}>
-      {({pressed}) => (
-        <Image
-          style={[
-            a.flex_1,
-            a.mb_sm,
-            a.rounded_sm,
-            {aspectRatio: 1, opacity: pressed ? 0.8 : 1},
-            t.atoms.bg_contrast_25,
-          ]}
-          source={{
-            uri: gif.media_formats.tinygif.url,
-          }}
-          contentFit="cover"
-          accessibilityLabel={gif.title}
-          accessibilityHint=""
-          cachePolicy="none"
-          accessibilityIgnoresInvertColors
-        />
-      )}
-    </Button>
-  )
-}
diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx
index 1afc588da..6023b5808 100644
--- a/src/components/dialogs/GifSelect.tsx
+++ b/src/components/dialogs/GifSelect.tsx
@@ -6,10 +6,12 @@ import React, {
   useState,
 } from 'react'
 import {TextInput, View} from 'react-native'
-import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
+import {useWindowDimensions} from 'react-native'
+import {Image} from 'expo-image'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {logEvent} from '#/lib/statsig/statsig'
 import {cleanError} from '#/lib/strings/errors'
 import {isWeb} from '#/platform/detection'
 import {
@@ -19,7 +21,8 @@ import {
 } from '#/state/queries/tenor'
 import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
-import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {ListMethods} from '#/view/com/util/List'
+import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf'
 import * as Dialog from '#/components/Dialog'
 import * as TextField from '#/components/forms/TextField'
 import {useThrottledValue} from '#/components/hooks/useThrottledValue'
@@ -27,16 +30,18 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arr
 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
 import {Button, ButtonIcon, ButtonText} from '../Button'
 import {ListFooter, ListMaybePlaceholder} from '../Lists'
-import {GifPreview} from './GifSelect.shared'
+import {PortalComponent} from '../Portal'
 
 export function GifSelectDialog({
   controlRef,
   onClose,
   onSelectGif: onSelectGifProp,
+  Portal,
 }: {
   controlRef: React.RefObject<{open: () => void}>
   onClose: () => void
   onSelectGif: (gif: Gif) => void
+  Portal?: PortalComponent
 }) {
   const control = Dialog.useDialogControl()
 
@@ -59,8 +64,13 @@ export function GifSelectDialog({
   return (
     <Dialog.Outer
       control={control}
-      nativeOptions={{sheet: {snapPoints: ['100%']}}}
-      onClose={onClose}>
+      onClose={onClose}
+      Portal={Portal}
+      nativeOptions={{
+        bottomInset: 0,
+        // use system corner radius on iOS
+        ...ios({cornerRadius: undefined}),
+      }}>
       <Dialog.Handle />
       <ErrorBoundary renderError={renderErrorBoundary}>
         <GifList control={control} onSelectGif={onSelectGif} />
@@ -80,9 +90,10 @@ function GifList({
   const t = useTheme()
   const {gtMobile} = useBreakpoints()
   const textInputRef = useRef<TextInput>(null)
-  const listRef = useRef<BottomSheetFlatListMethods>(null)
+  const listRef = useRef<ListMethods>(null)
   const [undeferredSearch, setSearch] = useState('')
   const search = useThrottledValue(undeferredSearch, 500)
+  const {height} = useWindowDimensions()
 
   const isSearching = search.length > 0
 
@@ -95,7 +106,7 @@ function GifList({
     isFetchingNextPage,
     hasNextPage,
     error,
-    isLoading,
+    isPending,
     isError,
     refetch,
   } = isSearching ? searchQuery : trendingQuery
@@ -132,6 +143,7 @@ function GifList({
     return (
       <View
         style={[
+          native(a.pt_4xl),
           a.relative,
           a.mb_lg,
           a.flex_row,
@@ -196,13 +208,14 @@ function GifList({
         data={flattenedData}
         renderItem={renderItem}
         numColumns={gtMobile ? 3 : 2}
-        columnWrapperStyle={a.gap_sm}
+        columnWrapperStyle={[a.gap_sm]}
+        contentContainerStyle={[native([a.px_xl, {minHeight: height}])]}
         ListHeaderComponent={
           <>
             {listHeader}
             {!hasData && (
               <ListMaybePlaceholder
-                isLoading={isLoading}
+                isLoading={isPending}
                 isError={isError}
                 onRetry={refetch}
                 onGoBack={onGoBack}
@@ -273,3 +286,47 @@ function DialogError({details}: {details?: string}) {
     </Dialog.ScrollableInner>
   )
 }
+
+export function GifPreview({
+  gif,
+  onSelectGif,
+}: {
+  gif: Gif
+  onSelectGif: (gif: Gif) => void
+}) {
+  const {gtTablet} = useBreakpoints()
+  const {_} = useLingui()
+  const t = useTheme()
+
+  const onPress = useCallback(() => {
+    logEvent('composer:gif:select', {})
+    onSelectGif(gif)
+  }, [onSelectGif, gif])
+
+  return (
+    <Button
+      label={_(msg`Select GIF "${gif.title}"`)}
+      style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
+      onPress={onPress}>
+      {({pressed}) => (
+        <Image
+          style={[
+            a.flex_1,
+            a.mb_sm,
+            a.rounded_sm,
+            {aspectRatio: 1, opacity: pressed ? 0.8 : 1},
+            t.atoms.bg_contrast_25,
+          ]}
+          source={{
+            uri: gif.media_formats.tinygif.url,
+          }}
+          contentFit="cover"
+          accessibilityLabel={gif.title}
+          accessibilityHint=""
+          cachePolicy="none"
+          accessibilityIgnoresInvertColors
+        />
+      )}
+    </Button>
+  )
+}
diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx
index 81a614103..c3aae8f0d 100644
--- a/src/components/dialogs/MutedWords.tsx
+++ b/src/components/dialogs/MutedWords.tsx
@@ -30,11 +30,14 @@ import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/P
 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 import {Loader} from '#/components/Loader'
+import {createPortalGroup} from '#/components/Portal'
 import * as Prompt from '#/components/Prompt'
 import {Text} from '#/components/Typography'
 
 const ONE_DAY = 24 * 60 * 60 * 1000
 
+const Portal = createPortalGroup()
+
 export function MutedWordsDialog() {
   const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
   return (
@@ -105,307 +108,349 @@ function MutedWordsInner() {
   }, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
 
   return (
-    <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
-      <View>
-        <Text
-          style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
-          <Trans>Add muted words and tags</Trans>
-        </Text>
-        <Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
-          <Trans>
-            Posts can be muted based on their text, their tags, or both. We
-            recommend avoiding common words that appear in many posts, since it
-            can result in no posts being shown.
-          </Trans>
-        </Text>
-
-        <View style={[a.pb_sm]}>
-          <Dialog.Input
-            autoCorrect={false}
-            autoCapitalize="none"
-            autoComplete="off"
-            label={_(msg`Enter a word or tag`)}
-            placeholder={_(msg`Enter a word or tag`)}
-            value={field}
-            onChangeText={value => {
-              if (error) {
-                setError('')
-              }
-              setField(value)
-            }}
-            onSubmitEditing={submit}
-          />
-        </View>
+    <Portal.Provider>
+      <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
+        <View>
+          <Text
+            style={[
+              a.text_md,
+              a.font_bold,
+              a.pb_sm,
+              t.atoms.text_contrast_high,
+            ]}>
+            <Trans>Add muted words and tags</Trans>
+          </Text>
+          <Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              Posts can be muted based on their text, their tags, or both. We
+              recommend avoiding common words that appear in many posts, since
+              it can result in no posts being shown.
+            </Trans>
+          </Text>
 
-        <View style={[a.pb_xl, a.gap_sm]}>
-          <Toggle.Group
-            label={_(msg`Select how long to mute this word for.`)}
-            type="radio"
-            values={durations}
-            onChange={setDurations}>
-            <Text
-              style={[
-                a.pb_xs,
-                a.text_sm,
-                a.font_bold,
-                t.atoms.text_contrast_medium,
-              ]}>
-              <Trans>Duration:</Trans>
-            </Text>
+          <View style={[a.pb_sm]}>
+            <Dialog.Input
+              autoCorrect={false}
+              autoCapitalize="none"
+              autoComplete="off"
+              label={_(msg`Enter a word or tag`)}
+              placeholder={_(msg`Enter a word or tag`)}
+              value={field}
+              onChangeText={value => {
+                if (error) {
+                  setError('')
+                }
+                setField(value)
+              }}
+              onSubmitEditing={submit}
+            />
+          </View>
+
+          <View style={[a.pb_xl, a.gap_sm]}>
+            <Toggle.Group
+              label={_(msg`Select how long to mute this word for.`)}
+              type="radio"
+              values={durations}
+              onChange={setDurations}>
+              <Text
+                style={[
+                  a.pb_xs,
+                  a.text_sm,
+                  a.font_bold,
+                  t.atoms.text_contrast_medium,
+                ]}>
+                <Trans>Duration:</Trans>
+              </Text>
 
-            <View
-              style={[
-                gtMobile && [a.flex_row, a.align_center, a.justify_start],
-                a.gap_sm,
-              ]}>
               <View
                 style={[
-                  a.flex_1,
-                  a.flex_row,
-                  a.justify_start,
-                  a.align_center,
+                  gtMobile && [a.flex_row, a.align_center, a.justify_start],
                   a.gap_sm,
                 ]}>
-                <Toggle.Item
-                  label={_(msg`Mute this word until you unmute it`)}
-                  name="forever"
-                  style={[a.flex_1]}>
-                  <TargetToggle>
-                    <View
-                      style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
-                      <Toggle.Radio />
-                      <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
-                        <Trans>Forever</Trans>
-                      </Toggle.LabelText>
-                    </View>
-                  </TargetToggle>
-                </Toggle.Item>
+                <View
+                  style={[
+                    a.flex_1,
+                    a.flex_row,
+                    a.justify_start,
+                    a.align_center,
+                    a.gap_sm,
+                  ]}>
+                  <Toggle.Item
+                    label={_(msg`Mute this word until you unmute it`)}
+                    name="forever"
+                    style={[a.flex_1]}>
+                    <TargetToggle>
+                      <View
+                        style={[
+                          a.flex_1,
+                          a.flex_row,
+                          a.align_center,
+                          a.gap_sm,
+                        ]}>
+                        <Toggle.Radio />
+                        <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
+                          <Trans>Forever</Trans>
+                        </Toggle.LabelText>
+                      </View>
+                    </TargetToggle>
+                  </Toggle.Item>
+
+                  <Toggle.Item
+                    label={_(msg`Mute this word for 24 hours`)}
+                    name="24_hours"
+                    style={[a.flex_1]}>
+                    <TargetToggle>
+                      <View
+                        style={[
+                          a.flex_1,
+                          a.flex_row,
+                          a.align_center,
+                          a.gap_sm,
+                        ]}>
+                        <Toggle.Radio />
+                        <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
+                          <Trans>24 hours</Trans>
+                        </Toggle.LabelText>
+                      </View>
+                    </TargetToggle>
+                  </Toggle.Item>
+                </View>
 
-                <Toggle.Item
-                  label={_(msg`Mute this word for 24 hours`)}
-                  name="24_hours"
-                  style={[a.flex_1]}>
-                  <TargetToggle>
-                    <View
-                      style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
-                      <Toggle.Radio />
-                      <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
-                        <Trans>24 hours</Trans>
-                      </Toggle.LabelText>
-                    </View>
-                  </TargetToggle>
-                </Toggle.Item>
+                <View
+                  style={[
+                    a.flex_1,
+                    a.flex_row,
+                    a.justify_start,
+                    a.align_center,
+                    a.gap_sm,
+                  ]}>
+                  <Toggle.Item
+                    label={_(msg`Mute this word for 7 days`)}
+                    name="7_days"
+                    style={[a.flex_1]}>
+                    <TargetToggle>
+                      <View
+                        style={[
+                          a.flex_1,
+                          a.flex_row,
+                          a.align_center,
+                          a.gap_sm,
+                        ]}>
+                        <Toggle.Radio />
+                        <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
+                          <Trans>7 days</Trans>
+                        </Toggle.LabelText>
+                      </View>
+                    </TargetToggle>
+                  </Toggle.Item>
+
+                  <Toggle.Item
+                    label={_(msg`Mute this word for 30 days`)}
+                    name="30_days"
+                    style={[a.flex_1]}>
+                    <TargetToggle>
+                      <View
+                        style={[
+                          a.flex_1,
+                          a.flex_row,
+                          a.align_center,
+                          a.gap_sm,
+                        ]}>
+                        <Toggle.Radio />
+                        <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
+                          <Trans>30 days</Trans>
+                        </Toggle.LabelText>
+                      </View>
+                    </TargetToggle>
+                  </Toggle.Item>
+                </View>
               </View>
+            </Toggle.Group>
 
-              <View
+            <Toggle.Group
+              label={_(
+                msg`Select what content this mute word should apply to.`,
+              )}
+              type="radio"
+              values={targets}
+              onChange={setTargets}>
+              <Text
                 style={[
-                  a.flex_1,
-                  a.flex_row,
-                  a.justify_start,
-                  a.align_center,
-                  a.gap_sm,
+                  a.pb_xs,
+                  a.text_sm,
+                  a.font_bold,
+                  t.atoms.text_contrast_medium,
                 ]}>
+                <Trans>Mute in:</Trans>
+              </Text>
+
+              <View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
                 <Toggle.Item
-                  label={_(msg`Mute this word for 7 days`)}
-                  name="7_days"
+                  label={_(msg`Mute this word in post text and tags`)}
+                  name="content"
                   style={[a.flex_1]}>
                   <TargetToggle>
                     <View
                       style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
                       <Toggle.Radio />
                       <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
-                        <Trans>7 days</Trans>
+                        <Trans>Text & tags</Trans>
                       </Toggle.LabelText>
                     </View>
+                    <PageText size="sm" />
                   </TargetToggle>
                 </Toggle.Item>
 
                 <Toggle.Item
-                  label={_(msg`Mute this word for 30 days`)}
-                  name="30_days"
+                  label={_(msg`Mute this word in tags only`)}
+                  name="tag"
                   style={[a.flex_1]}>
                   <TargetToggle>
                     <View
                       style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
                       <Toggle.Radio />
                       <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
-                        <Trans>30 days</Trans>
+                        <Trans>Tags only</Trans>
                       </Toggle.LabelText>
                     </View>
+                    <Hashtag size="sm" />
                   </TargetToggle>
                 </Toggle.Item>
               </View>
-            </View>
-          </Toggle.Group>
+            </Toggle.Group>
 
-          <Toggle.Group
-            label={_(msg`Select what content this mute word should apply to.`)}
-            type="radio"
-            values={targets}
-            onChange={setTargets}>
-            <Text
-              style={[
-                a.pb_xs,
-                a.text_sm,
-                a.font_bold,
-                t.atoms.text_contrast_medium,
-              ]}>
-              <Trans>Mute in:</Trans>
-            </Text>
-
-            <View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
+            <View>
+              <Text
+                style={[
+                  a.pb_xs,
+                  a.text_sm,
+                  a.font_bold,
+                  t.atoms.text_contrast_medium,
+                ]}>
+                <Trans>Options:</Trans>
+              </Text>
               <Toggle.Item
-                label={_(msg`Mute this word in post text and tags`)}
-                name="content"
-                style={[a.flex_1]}>
+                label={_(msg`Do not apply this mute word to users you follow`)}
+                name="exclude_following"
+                style={[a.flex_row, a.justify_between]}
+                value={excludeFollowing}
+                onChange={setExcludeFollowing}>
                 <TargetToggle>
                   <View
                     style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
-                    <Toggle.Radio />
+                    <Toggle.Checkbox />
                     <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
-                      <Trans>Text & tags</Trans>
+                      <Trans>Exclude users you follow</Trans>
                     </Toggle.LabelText>
                   </View>
-                  <PageText size="sm" />
                 </TargetToggle>
               </Toggle.Item>
+            </View>
 
-              <Toggle.Item
-                label={_(msg`Mute this word in tags only`)}
-                name="tag"
-                style={[a.flex_1]}>
-                <TargetToggle>
-                  <View
-                    style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
-                    <Toggle.Radio />
-                    <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
-                      <Trans>Tags only</Trans>
-                    </Toggle.LabelText>
-                  </View>
-                  <Hashtag size="sm" />
-                </TargetToggle>
-              </Toggle.Item>
+            <View style={[a.pt_xs]}>
+              <Button
+                disabled={isPending || !field}
+                label={_(msg`Add mute word for configured settings`)}
+                size="large"
+                color="primary"
+                variant="solid"
+                style={[]}
+                onPress={submit}>
+                <ButtonText>
+                  <Trans>Add</Trans>
+                </ButtonText>
+                <ButtonIcon icon={isPending ? Loader : Plus} position="right" />
+              </Button>
             </View>
-          </Toggle.Group>
 
-          <View>
+            {error && (
+              <View
+                style={[
+                  a.mb_lg,
+                  a.flex_row,
+                  a.rounded_sm,
+                  a.p_md,
+                  a.mb_xs,
+                  t.atoms.bg_contrast_25,
+                  {
+                    backgroundColor: t.palette.negative_400,
+                  },
+                ]}>
+                <Text
+                  style={[
+                    a.italic,
+                    {color: t.palette.white},
+                    native({marginTop: 2}),
+                  ]}>
+                  {error}
+                </Text>
+              </View>
+            )}
+          </View>
+
+          <Divider />
+
+          <View style={[a.pt_2xl]}>
             <Text
               style={[
-                a.pb_xs,
-                a.text_sm,
+                a.text_md,
                 a.font_bold,
-                t.atoms.text_contrast_medium,
+                a.pb_md,
+                t.atoms.text_contrast_high,
               ]}>
-              <Trans>Options:</Trans>
+              <Trans>Your muted words</Trans>
             </Text>
-            <Toggle.Item
-              label={_(msg`Do not apply this mute word to users you follow`)}
-              name="exclude_following"
-              style={[a.flex_row, a.justify_between]}
-              value={excludeFollowing}
-              onChange={setExcludeFollowing}>
-              <TargetToggle>
-                <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
-                  <Toggle.Checkbox />
-                  <Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
-                    <Trans>Exclude users you follow</Trans>
-                  </Toggle.LabelText>
-                </View>
-              </TargetToggle>
-            </Toggle.Item>
-          </View>
-
-          <View style={[a.pt_xs]}>
-            <Button
-              disabled={isPending || !field}
-              label={_(msg`Add mute word for configured settings`)}
-              size="large"
-              color="primary"
-              variant="solid"
-              style={[]}
-              onPress={submit}>
-              <ButtonText>
-                <Trans>Add</Trans>
-              </ButtonText>
-              <ButtonIcon icon={isPending ? Loader : Plus} position="right" />
-            </Button>
-          </View>
 
-          {error && (
-            <View
-              style={[
-                a.mb_lg,
-                a.flex_row,
-                a.rounded_sm,
-                a.p_md,
-                a.mb_xs,
-                t.atoms.bg_contrast_25,
-                {
-                  backgroundColor: t.palette.negative_400,
-                },
-              ]}>
-              <Text
+            {isPreferencesLoading ? (
+              <Loader />
+            ) : preferencesError || !preferences ? (
+              <View
                 style={[
-                  a.italic,
-                  {color: t.palette.white},
-                  native({marginTop: 2}),
+                  a.py_md,
+                  a.px_lg,
+                  a.rounded_md,
+                  t.atoms.bg_contrast_25,
                 ]}>
-                {error}
-              </Text>
-            </View>
-          )}
-        </View>
-
-        <Divider />
-
-        <View style={[a.pt_2xl]}>
-          <Text
-            style={[
-              a.text_md,
-              a.font_bold,
-              a.pb_md,
-              t.atoms.text_contrast_high,
-            ]}>
-            <Trans>Your muted words</Trans>
-          </Text>
+                <Text style={[a.italic, t.atoms.text_contrast_high]}>
+                  <Trans>
+                    We're sorry, but we weren't able to load your muted words at
+                    this time. Please try again.
+                  </Trans>
+                </Text>
+              </View>
+            ) : preferences.moderationPrefs.mutedWords.length ? (
+              [...preferences.moderationPrefs.mutedWords]
+                .reverse()
+                .map((word, i) => (
+                  <MutedWordRow
+                    key={word.value + i}
+                    word={word}
+                    style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
+                  />
+                ))
+            ) : (
+              <View
+                style={[
+                  a.py_md,
+                  a.px_lg,
+                  a.rounded_md,
+                  t.atoms.bg_contrast_25,
+                ]}>
+                <Text style={[a.italic, t.atoms.text_contrast_high]}>
+                  <Trans>You haven't muted any words or tags yet</Trans>
+                </Text>
+              </View>
+            )}
+          </View>
 
-          {isPreferencesLoading ? (
-            <Loader />
-          ) : preferencesError || !preferences ? (
-            <View
-              style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
-              <Text style={[a.italic, t.atoms.text_contrast_high]}>
-                <Trans>
-                  We're sorry, but we weren't able to load your muted words at
-                  this time. Please try again.
-                </Trans>
-              </Text>
-            </View>
-          ) : preferences.moderationPrefs.mutedWords.length ? (
-            [...preferences.moderationPrefs.mutedWords]
-              .reverse()
-              .map((word, i) => (
-                <MutedWordRow
-                  key={word.value + i}
-                  word={word}
-                  style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
-                />
-              ))
-          ) : (
-            <View
-              style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
-              <Text style={[a.italic, t.atoms.text_contrast_high]}>
-                <Trans>You haven't muted any words or tags yet</Trans>
-              </Text>
-            </View>
-          )}
+          {isNative && <View style={{height: 20}} />}
         </View>
 
-        {isNative && <View style={{height: 20}} />}
-      </View>
+        <Dialog.Close />
+      </Dialog.ScrollableInner>
 
-      <Dialog.Close />
-    </Dialog.ScrollableInner>
+      <Portal.Outlet />
+    </Portal.Provider>
   )
 }
 
@@ -437,6 +482,7 @@ function MutedWordRow({
         onConfirm={remove}
         confirmButtonCta={_(msg`Remove`)}
         confirmButtonColor="negative"
+        Portal={Portal.Portal}
       />
 
       <View
diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx
index 47eefae6f..bddc49968 100644
--- a/src/components/dialogs/PostInteractionSettingsDialog.tsx
+++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx
@@ -37,6 +37,7 @@ import * as Toggle from '#/components/forms/Toggle'
 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 import {Loader} from '#/components/Loader'
+import {PortalComponent} from '#/components/Portal'
 import {Text} from '#/components/Typography'
 
 export type PostInteractionSettingsFormProps = {
@@ -54,13 +55,15 @@ export type PostInteractionSettingsFormProps = {
 
 export function PostInteractionSettingsControlledDialog({
   control,
+  Portal,
   ...rest
 }: PostInteractionSettingsFormProps & {
   control: Dialog.DialogControlProps
+  Portal?: PortalComponent
 }) {
   const {_} = useLingui()
   return (
-    <Dialog.Outer control={control}>
+    <Dialog.Outer control={control} Portal={Portal}>
       <Dialog.Handle />
       <Dialog.ScrollableInner
         label={_(msg`Edit post interaction settings`)}
@@ -231,7 +234,6 @@ export function PostInteractionSettingsForm({
 }: PostInteractionSettingsFormProps) {
   const t = useTheme()
   const {_} = useLingui()
-  const control = Dialog.useDialogContext()
   const {data: lists} = useMyListsQuery('curate')
   const [quotesEnabled, setQuotesEnabled] = React.useState(
     !(
@@ -437,7 +439,6 @@ export function PostInteractionSettingsForm({
       <Button
         label={_(msg`Save`)}
         onPress={onSave}
-        onAccessibilityEscape={control.close}
         color="primary"
         size="large"
         variant="solid"
diff --git a/src/components/dialogs/SwitchAccount.tsx b/src/components/dialogs/SwitchAccount.tsx
index 0bd4bcb8c..ea870e2da 100644
--- a/src/components/dialogs/SwitchAccount.tsx
+++ b/src/components/dialogs/SwitchAccount.tsx
@@ -43,7 +43,6 @@ export function SwitchAccountDialog({
   return (
     <Dialog.Outer control={control}>
       <Dialog.Handle />
-
       <Dialog.ScrollableInner label={_(msg`Switch Account`)}>
         <View style={[a.gap_lg]}>
           <Text style={[a.text_2xl, a.font_bold]}>
diff --git a/src/components/dialogs/nuxs/NeueTypography.tsx b/src/components/dialogs/nuxs/NeueTypography.tsx
index f160c8774..f29dc356d 100644
--- a/src/components/dialogs/nuxs/NeueTypography.tsx
+++ b/src/components/dialogs/nuxs/NeueTypography.tsx
@@ -44,7 +44,6 @@ export function NeueTypography() {
   return (
     <Dialog.Outer control={control} onClose={onClose}>
       <Dialog.Handle />
-
       <Dialog.ScrollableInner label={_(msg`Introducing new font settings`)}>
         <View style={[a.gap_xl]}>
           <View style={[a.gap_md]}>
diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx
index a4fa625fa..affc292c1 100644
--- a/src/components/dms/ConvoMenu.tsx
+++ b/src/components/dms/ConvoMenu.tsx
@@ -136,7 +136,7 @@ let ConvoMenu = ({
           <Menu.Outer>
             <Menu.Item
               label={_(msg`Leave conversation`)}
-              onPress={leaveConvoControl.open}>
+              onPress={() => leaveConvoControl.open()}>
               <Menu.ItemText>
                 <Trans>Leave conversation</Trans>
               </Menu.ItemText>
@@ -195,7 +195,7 @@ let ConvoMenu = ({
               </Menu.Item>
               <Menu.Item
                 label={_(msg`Report conversation`)}
-                onPress={reportControl.open}>
+                onPress={() => reportControl.open()}>
                 <Menu.ItemText>
                   <Trans>Report conversation</Trans>
                 </Menu.ItemText>
@@ -206,7 +206,7 @@ let ConvoMenu = ({
             <Menu.Group>
               <Menu.Item
                 label={_(msg`Leave conversation`)}
-                onPress={leaveConvoControl.open}>
+                onPress={() => leaveConvoControl.open()}>
                 <Menu.ItemText>
                   <Trans>Leave conversation</Trans>
                 </Menu.ItemText>
diff --git a/src/components/dms/MessageMenu.tsx b/src/components/dms/MessageMenu.tsx
index 2978d2b22..8680a68bf 100644
--- a/src/components/dms/MessageMenu.tsx
+++ b/src/components/dms/MessageMenu.tsx
@@ -7,11 +7,11 @@ import {useLingui} from '@lingui/react'
 
 import {richTextToString} from '#/lib/strings/rich-text-helpers'
 import {getTranslatorLink} from '#/locale/helpers'
+import {isWeb} from '#/platform/detection'
+import {useConvoActive} from '#/state/messages/convo'
 import {useLanguagePrefs} from '#/state/preferences'
 import {useOpenLink} from '#/state/preferences/in-app-browser'
-import {isWeb} from 'platform/detection'
-import {useConvoActive} from 'state/messages/convo'
-import {useSession} from 'state/session'
+import {useSession} from '#/state/session'
 import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, useTheme} from '#/alf'
 import {ReportDialog} from '#/components/dms/ReportDialog'
@@ -120,7 +120,7 @@ export let MessageMenu = ({
             <Menu.Item
               testID="messageDropdownDeleteBtn"
               label={_(msg`Delete message for me`)}
-              onPress={deleteControl.open}>
+              onPress={() => deleteControl.open()}>
               <Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
               <Menu.ItemIcon icon={Trash} position="right" />
             </Menu.Item>
@@ -128,7 +128,7 @@ export let MessageMenu = ({
               <Menu.Item
                 testID="messageDropdownReportBtn"
                 label={_(msg`Report message`)}
-                onPress={reportControl.open}>
+                onPress={() => reportControl.open()}>
                 <Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
                 <Menu.ItemIcon icon={Warning} position="right" />
               </Menu.Item>
diff --git a/src/components/dms/ReportDialog.tsx b/src/components/dms/ReportDialog.tsx
index 2dcd77854..06d69ff4b 100644
--- a/src/components/dms/ReportDialog.tsx
+++ b/src/components/dms/ReportDialog.tsx
@@ -10,13 +10,11 @@ import {useLingui} from '@lingui/react'
 import {useMutation} from '@tanstack/react-query'
 
 import {ReportOption} from '#/lib/moderation/useReportOptions'
-import {isAndroid} from '#/platform/detection'
 import {useAgent} from '#/state/session'
 import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
 import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import * as Dialog from '#/components/Dialog'
-import {KeyboardControllerPadding} from '#/components/KeyboardControllerPadding'
 import {Button, ButtonIcon, ButtonText} from '../Button'
 import {Divider} from '../Divider'
 import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '../icons/Chevron'
@@ -41,14 +39,11 @@ let ReportDialog = ({
 }): React.ReactNode => {
   const {_} = useLingui()
   return (
-    <Dialog.Outer
-      control={control}
-      nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
+    <Dialog.Outer control={control}>
       <Dialog.Handle />
       <Dialog.ScrollableInner label={_(msg`Report this message`)}>
         <DialogInner params={params} />
         <Dialog.Close />
-        <KeyboardControllerPadding />
       </Dialog.ScrollableInner>
     </Dialog.Outer>
   )
diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx
index 19f6eb6df..e80fef2d7 100644
--- a/src/components/dms/dialogs/NewChatDialog.tsx
+++ b/src/components/dms/dialogs/NewChatDialog.tsx
@@ -2,9 +2,9 @@ import React, {useCallback} from 'react'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {logEvent} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
-import {logEvent} from 'lib/statsig/statsig'
 import {FAB} from '#/view/com/util/fab/FAB'
 import * as Toast from '#/view/com/util/Toast'
 import {useTheme} from '#/alf'
@@ -55,10 +55,8 @@ export function NewChat({
         accessibilityHint=""
       />
 
-      <Dialog.Outer
-        control={control}
-        testID="newChatDialog"
-        nativeOptions={{sheet: {snapPoints: ['100%']}}}>
+      <Dialog.Outer control={control} testID="newChatDialog">
+        <Dialog.Handle />
         <SearchablePeopleList
           title={_(msg`Start a new chat`)}
           onSelectChat={onCreateChat}
diff --git a/src/components/dms/dialogs/SearchablePeopleList.tsx b/src/components/dms/dialogs/SearchablePeopleList.tsx
index a13dfe509..a5687a096 100644
--- a/src/components/dms/dialogs/SearchablePeopleList.tsx
+++ b/src/components/dms/dialogs/SearchablePeopleList.tsx
@@ -5,10 +5,8 @@ import React, {
   useRef,
   useState,
 } from 'react'
-import type {TextInput as TextInputType} from 'react-native'
-import {View} from 'react-native'
+import {TextInput, View} from 'react-native'
 import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
-import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -16,18 +14,17 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {isWeb} from '#/platform/detection'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
 import {useListConvosQuery} from '#/state/queries/messages/list-converations'
 import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
 import {useSession} from '#/state/session'
-import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
+import {ListMethods} from '#/view/com/util/List'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, native, useTheme, web} from '#/alf'
-import {Button} from '#/components/Button'
+import {Button, ButtonIcon} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
-import {TextInput} from '#/components/dms/dialogs/TextInput'
 import {canBeMessaged} from '#/components/dms/util'
 import {useInteractionState} from '#/components/hooks/useInteractionState'
-import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 import {Text} from '#/components/Typography'
@@ -66,9 +63,9 @@ export function SearchablePeopleList({
   const {_} = useLingui()
   const moderationOpts = useModerationOpts()
   const control = Dialog.useDialogContext()
-  const listRef = useRef<BottomSheetFlatListMethods>(null)
+  const listRef = useRef<ListMethods>(null)
   const {currentAccount} = useSession()
-  const inputRef = useRef<TextInputType>(null)
+  const inputRef = useRef<TextInput>(null)
 
   const [searchText, setSearchText] = useState('')
 
@@ -101,15 +98,15 @@ export function SearchablePeopleList({
           })
         }
 
-        _items = _items.sort(a => {
+        _items = _items.sort(item => {
           // @ts-ignore
-          return a.enabled ? -1 : 1
+          return item.enabled ? -1 : 1
         })
       }
     } else {
       const placeholders: Item[] = Array(10)
         .fill(0)
-        .map((_, i) => ({
+        .map((__, i) => ({
           type: 'placeholder',
           key: i + '',
         }))
@@ -155,9 +152,9 @@ export function SearchablePeopleList({
           }
 
           // only sort follows
-          followsItems = followsItems.sort(a => {
+          followsItems = followsItems.sort(item => {
             // @ts-ignore
-            return a.enabled ? -1 : 1
+            return item.enabled ? -1 : 1
           })
 
           // then append
@@ -177,9 +174,9 @@ export function SearchablePeopleList({
           }
         }
 
-        _items = _items.sort(a => {
+        _items = _items.sort(item => {
           // @ts-ignore
-          return a.enabled ? -1 : 1
+          return item.enabled ? -1 : 1
         })
       } else {
         _items.push(...placeholders)
@@ -242,57 +239,46 @@ export function SearchablePeopleList({
       <View
         style={[
           a.relative,
-          a.pt_md,
+          web(a.pt_lg),
+          native(a.pt_4xl),
           a.pb_xs,
           a.px_lg,
           a.border_b,
           t.atoms.border_contrast_low,
           t.atoms.bg,
-          native([a.pt_lg]),
         ]}>
-        <View
-          style={[
-            a.relative,
-            native(a.align_center),
-            a.justify_center,
-            {height: 32},
-          ]}>
-          <Button
-            label={_(msg`Close`)}
-            size="small"
-            shape="round"
-            variant="ghost"
-            color="secondary"
-            style={[
-              a.absolute,
-              a.z_20,
-              native({
-                left: -7,
-              }),
-              web({
-                right: -4,
-              }),
-            ]}
-            onPress={() => control.close()}>
-            {isWeb ? (
-              <X size="md" fill={t.palette.contrast_500} />
-            ) : (
-              <ChevronLeft size="md" fill={t.palette.contrast_500} />
-            )}
-          </Button>
+        <View style={[a.relative, native(a.align_center), a.justify_center]}>
           <Text
             style={[
               a.z_10,
               a.text_lg,
-              a.font_bold,
+              a.font_heavy,
               a.leading_tight,
               t.atoms.text_contrast_high,
             ]}>
             {title}
           </Text>
+          {isWeb ? (
+            <Button
+              label={_(msg`Close`)}
+              size="small"
+              shape="round"
+              variant={isWeb ? 'ghost' : 'solid'}
+              color="secondary"
+              style={[
+                a.absolute,
+                a.z_20,
+                web({right: -4}),
+                native({right: 0}),
+                native({height: 32, width: 32, borderRadius: 16}),
+              ]}
+              onPress={() => control.close()}>
+              <ButtonIcon icon={X} size="md" />
+            </Button>
+          ) : null}
         </View>
 
-        <View style={[native([a.pt_sm]), web([a.pt_xs])]}>
+        <View style={[, web([a.pt_xs])]}>
           <SearchInput
             inputRef={inputRef}
             value={searchText}
@@ -309,7 +295,6 @@ export function SearchablePeopleList({
     t.atoms.border_contrast_low,
     t.atoms.bg,
     t.atoms.text_contrast_high,
-    t.palette.contrast_500,
     _,
     title,
     searchText,
@@ -326,14 +311,7 @@ export function SearchablePeopleList({
       keyExtractor={(item: Item) => item.key}
       style={[
         web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
-        native({
-          height: '100%',
-          paddingHorizontal: 0,
-          marginTop: 0,
-          paddingTop: 0,
-          borderTopLeftRadius: 40,
-          borderTopRightRadius: 40,
-        }),
+        native({height: '100%'}),
       ]}
       webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
       keyboardDismissMode="on-drag"
@@ -396,7 +374,8 @@ function ProfileCard({
           <View style={[a.flex_1, a.gap_2xs]}>
             <Text
               style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]}
-              numberOfLines={1}>
+              numberOfLines={1}
+              emoji>
               {displayName}
             </Text>
             <Text
@@ -474,7 +453,7 @@ function SearchInput({
   value: string
   onChangeText: (text: string) => void
   onEscape: () => void
-  inputRef: React.RefObject<TextInputType>
+  inputRef: React.RefObject<TextInput>
 }) {
   const t = useTheme()
   const {_} = useLingui()
diff --git a/src/components/dms/dialogs/ShareViaChatDialog.tsx b/src/components/dms/dialogs/ShareViaChatDialog.tsx
index 01906a430..38b558343 100644
--- a/src/components/dms/dialogs/ShareViaChatDialog.tsx
+++ b/src/components/dms/dialogs/ShareViaChatDialog.tsx
@@ -2,9 +2,9 @@ import React, {useCallback} from 'react'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {logEvent} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
-import {logEvent} from 'lib/statsig/statsig'
 import * as Toast from '#/view/com/util/Toast'
 import * as Dialog from '#/components/Dialog'
 import {SearchablePeopleList} from './SearchablePeopleList'
@@ -17,10 +17,8 @@ export function SendViaChatDialog({
   onSelectChat: (chatId: string) => void
 }) {
   return (
-    <Dialog.Outer
-      control={control}
-      testID="sendViaChatChatDialog"
-      nativeOptions={{sheet: {snapPoints: ['100%']}}}>
+    <Dialog.Outer control={control} testID="sendViaChatChatDialog">
+      <Dialog.Handle />
       <SendViaChatDialogInner control={control} onSelectChat={onSelectChat} />
     </Dialog.Outer>
   )
diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx
index 6dc387b23..4e3695bbf 100644
--- a/src/components/forms/Toggle.tsx
+++ b/src/components/forms/Toggle.tsx
@@ -2,8 +2,8 @@ import React from 'react'
 import {Pressable, View, ViewStyle} from 'react-native'
 import Animated, {LinearTransition} from 'react-native-reanimated'
 
+import {HITSLOP_10} from '#/lib/constants'
 import {isNative} from '#/platform/detection'
-import {HITSLOP_10} from 'lib/constants'
 import {
   atoms as a,
   flatten,
diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx
index e63cea93b..bf0d1905e 100644
--- a/src/components/moderation/LabelsOnMeDialog.tsx
+++ b/src/components/moderation/LabelsOnMeDialog.tsx
@@ -32,7 +32,6 @@ export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
   return (
     <Dialog.Outer control={props.control}>
       <Dialog.Handle />
-
       <LabelsOnMeDialogInner {...props} />
     </Dialog.Outer>
   )
@@ -158,23 +157,25 @@ function Label({
       <Divider />
 
       <View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}>
-        <Text style={[t.atoms.text_contrast_medium]}>
-          {isSelfLabel ? (
+        {isSelfLabel ? (
+          <Text style={[t.atoms.text_contrast_medium]}>
             <Trans>This label was applied by you.</Trans>
-          ) : (
-            <Trans>
-              Source:{' '}
-              <InlineLinkText
-                label={sourceName}
-                to={makeProfileLink(
-                  labeler ? labeler.creator : {did: label.src, handle: ''},
-                )}
-                onPress={() => control.close()}>
-                {sourceName}
-              </InlineLinkText>
-            </Trans>
-          )}
-        </Text>
+          </Text>
+        ) : (
+          <View style={{flexDirection: 'row'}}>
+            <Text style={[t.atoms.text_contrast_medium]}>
+              <Trans>Source: </Trans>{' '}
+            </Text>
+            <InlineLinkText
+              label={sourceName}
+              to={makeProfileLink(
+                labeler ? labeler.creator : {did: label.src, handle: ''},
+              )}
+              onPress={() => control.close()}>
+              {sourceName}
+            </InlineLinkText>
+          </View>
+        )}
       </View>
     </View>
   )
@@ -236,24 +237,24 @@ function AppealForm({
 
   return (
     <>
-      <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
-        <Trans>Appeal "{strings.name}" label</Trans>
-      </Text>
-      <Text style={[a.text_md, a.leading_snug]}>
-        <Trans>
-          This appeal will be sent to{' '}
-          <InlineLinkText
-            label={sourceName}
-            to={makeProfileLink(
-              labeler ? labeler.creator : {did: label.src, handle: ''},
-            )}
-            onPress={() => control.close()}
-            style={[a.text_md, a.leading_snug]}>
-            {sourceName}
-          </InlineLinkText>
-          .
-        </Trans>
-      </Text>
+      <View style={{flexWrap: 'wrap', flexDirection: 'row'}}>
+        <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
+          <Trans>Appeal "{strings.name}" label</Trans>
+        </Text>
+        <Text style={[a.text_md, a.leading_snug]}>
+          <Trans>This appeal will be sent to</Trans>{' '}
+        </Text>
+        <InlineLinkText
+          label={sourceName}
+          to={makeProfileLink(
+            labeler ? labeler.creator : {did: label.src, handle: ''},
+          )}
+          onPress={() => control.close()}
+          style={[a.text_md, a.leading_snug]}>
+          {sourceName}
+        </InlineLinkText>
+        <Text style={[a.text_md, a.leading_snug]}>.</Text>
+      </View>
       <View style={[a.my_md]}>
         <Dialog.Input
           label={_(msg`Text input field`)}
diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx
index 225917853..0a1fae67e 100644
--- a/src/components/moderation/ModerationDetailsDialog.tsx
+++ b/src/components/moderation/ModerationDetailsDialog.tsx
@@ -141,23 +141,24 @@ function ModerationDetailsDialogInner({
       {modcause?.type === 'label' && (
         <View style={[a.pt_lg]}>
           <Divider />
-          <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
-            {modcause.source.type === 'user' ? (
+          {modcause.source.type === 'user' ? (
+            <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
               <Trans>This label was applied by the author.</Trans>
-            ) : (
-              <Trans>
-                This label was applied by{' '}
-                <InlineLinkText
-                  label={desc.source || _(msg`an unknown labeler`)}
-                  to={makeProfileLink({did: modcause.label.src, handle: ''})}
-                  onPress={() => control.close()}
-                  style={a.text_md}>
-                  {desc.source || _(msg`an unknown labeler`)}
-                </InlineLinkText>
-                .
-              </Trans>
-            )}
-          </Text>
+            </Text>
+          ) : (
+            <>
+              <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
+                <Trans>This label was applied by </Trans>
+              </Text>
+              <InlineLinkText
+                label={desc.source || _(msg`an unknown labeler`)}
+                to={makeProfileLink({did: modcause.label.src, handle: ''})}
+                onPress={() => control.close()}
+                style={a.text_md}>
+                {desc.source || _(msg`an unknown labeler`)}
+              </InlineLinkText>
+            </>
+          )}
         </View>
       )}
 
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 8b7925004..c7608ae55 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -1,5 +1,4 @@
 import {
-  AppBskyEmbedDefs,
   AppBskyEmbedExternal,
   AppBskyEmbedImages,
   AppBskyEmbedRecord,
@@ -7,7 +6,6 @@ import {
   AppBskyEmbedVideo,
   AppBskyFeedPostgate,
   AtUri,
-  BlobRef,
   BskyAgent,
   ComAtprotoLabelDefs,
   RichText,
@@ -46,14 +44,7 @@ interface PostOpts {
     uri: string
     cid: string
   }
-  video?: {
-    blobRef: BlobRef
-    altText: string
-    captions: {lang: string; file: File}[]
-    aspectRatio?: AppBskyEmbedDefs.AspectRatio
-  }
   extLink?: ExternalEmbedDraft
-  images?: ComposerImage[]
   labels?: string[]
   threadgate: ThreadgateAllowUISetting[]
   postgate: AppBskyFeedPostgate.Record
@@ -230,13 +221,15 @@ async function resolveMedia(
   | AppBskyEmbedVideo.Main
   | undefined
 > {
-  if (opts.images?.length) {
+  const state = opts.composerState
+  const media = state.embed.media
+  if (media?.type === 'images') {
     logger.debug(`Uploading images`, {
-      count: opts.images.length,
+      count: media.images.length,
     })
     opts.onStateChange?.(`Uploading images...`)
     const images: AppBskyEmbedImages.Image[] = await Promise.all(
-      opts.images.map(async (image, i) => {
+      media.images.map(async (image, i) => {
         logger.debug(`Compressing image #${i}`)
         const {path, width, height, mime} = await compressImage(image)
         logger.debug(`Uploading image #${i}`)
@@ -253,9 +246,10 @@ async function resolveMedia(
       images,
     }
   }
-  if (opts.video) {
+  if (media?.type === 'video' && media.video.status === 'done') {
+    const video = media.video
     const captions = await Promise.all(
-      opts.video.captions
+      video.captions
         .filter(caption => caption.lang !== '')
         .map(async caption => {
           const {data} = await agent.uploadBlob(caption.file, {
@@ -266,13 +260,17 @@ async function resolveMedia(
     )
     return {
       $type: 'app.bsky.embed.video',
-      video: opts.video.blobRef,
-      alt: opts.video.altText || undefined,
+      video: video.pendingPublish.blobRef,
+      alt: video.altText || undefined,
       captions: captions.length === 0 ? undefined : captions,
-      aspectRatio: opts.video.aspectRatio,
+      aspectRatio: {
+        width: video.asset.width,
+        height: video.asset.height,
+      },
     }
   }
   if (opts.extLink) {
+    // TODO: Read this from composer state as well.
     if (opts.extLink.embed) {
       return undefined
     }
diff --git a/src/lib/media/video/upload.ts b/src/lib/media/video/upload.ts
index 3330370b3..720283a8d 100644
--- a/src/lib/media/video/upload.ts
+++ b/src/lib/media/video/upload.ts
@@ -7,8 +7,8 @@ import {nanoid} from 'nanoid/non-secure'
 import {AbortError} from '#/lib/async/cancelable'
 import {ServerError} from '#/lib/media/video/errors'
 import {CompressedVideo} from '#/lib/media/video/types'
-import {createVideoEndpointUrl, mimeToExt} from './util'
 import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
+import {createVideoEndpointUrl, mimeToExt} from './util'
 
 export async function uploadVideo({
   video,
diff --git a/src/lib/media/video/upload.web.ts b/src/lib/media/video/upload.web.ts
index ec65f96c9..d1b441a36 100644
--- a/src/lib/media/video/upload.web.ts
+++ b/src/lib/media/video/upload.web.ts
@@ -7,8 +7,8 @@ import {nanoid} from 'nanoid/non-secure'
 import {AbortError} from '#/lib/async/cancelable'
 import {ServerError} from '#/lib/media/video/errors'
 import {CompressedVideo} from '#/lib/media/video/types'
-import {createVideoEndpointUrl, mimeToExt} from './util'
 import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
+import {createVideoEndpointUrl, mimeToExt} from './util'
 
 export async function uploadVideo({
   video,
diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx
index 663418f22..73472ec33 100644
--- a/src/screens/Onboarding/StepProfile/index.tsx
+++ b/src/screens/Onboarding/StepProfile/index.tsx
@@ -32,6 +32,7 @@ import {
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
+import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
 import {IconCircle} from '#/components/IconCircle'
 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
 import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo'
@@ -89,15 +90,18 @@ export function StepProfile() {
     requestNotificationsPermission('StartOnboarding')
   }, [gate, requestNotificationsPermission])
 
+  const sheetWrapper = useSheetWrapper()
   const openPicker = React.useCallback(
     async (opts?: ImagePickerOptions) => {
-      const response = await launchImageLibraryAsync({
-        exif: false,
-        mediaTypes: MediaTypeOptions.Images,
-        quality: 1,
-        ...opts,
-        legacy: true,
-      })
+      const response = await sheetWrapper(
+        launchImageLibraryAsync({
+          exif: false,
+          mediaTypes: MediaTypeOptions.Images,
+          quality: 1,
+          ...opts,
+          legacy: true,
+        }),
+      )
 
       return (response.assets ?? [])
         .slice(0, 1)
@@ -121,7 +125,7 @@ export function StepProfile() {
           size: getDataUriSize(image.uri),
         }))
     },
-    [_, setError],
+    [_, setError, sheetWrapper],
   )
 
   const onContinue = React.useCallback(async () => {
@@ -168,9 +172,11 @@ export function StepProfile() {
 
     setError('')
 
-    const items = await openPicker({
-      aspect: [1, 1],
-    })
+    const items = await sheetWrapper(
+      openPicker({
+        aspect: [1, 1],
+      }),
+    )
     let image = items[0]
     if (!image) return
 
@@ -196,7 +202,13 @@ export function StepProfile() {
       image,
       useCreatedAvatar: false,
     }))
-  }, [requestPhotoAccessIfNeeded, setAvatar, openPicker, setError])
+  }, [
+    requestPhotoAccessIfNeeded,
+    setAvatar,
+    openPicker,
+    setError,
+    sheetWrapper,
+  ])
 
   const onSecondaryPress = React.useCallback(() => {
     if (avatar.useCreatedAvatar) {
@@ -286,7 +298,6 @@ export function StepProfile() {
       </View>
 
       <Dialog.Outer control={creatorControl}>
-        <Dialog.Handle />
         <Dialog.Inner
           label="Avatar creator"
           style={[
diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx
index 0aa863f7b..68803ac00 100644
--- a/src/screens/StarterPack/StarterPackScreen.tsx
+++ b/src/screens/StarterPack/StarterPackScreen.tsx
@@ -15,35 +15,35 @@ import {useNavigation} from '@react-navigation/native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
 import {useQueryClient} from '@tanstack/react-query'
 
+import {batchedUpdates} from '#/lib/batchedUpdates'
+import {HITSLOP_20} from '#/lib/constants'
+import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
+import {makeProfileLink, makeStarterPackLink} from '#/lib/routes/links'
+import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
+import {logEvent} from '#/lib/statsig/statsig'
 import {cleanError} from '#/lib/strings/errors'
+import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
 import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
+import {updateProfileShadow} from '#/state/cache/profile-shadow'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {getAllListMembers} from '#/state/queries/list-members'
+import {useResolvedStarterPackShortLink} from '#/state/queries/resolve-short-link'
+import {useResolveDidQuery} from '#/state/queries/resolve-uri'
+import {useShortenLink} from '#/state/queries/shorten-link'
 import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs'
+import {useStarterPackQuery} from '#/state/queries/starter-packs'
+import {useAgent, useSession} from '#/state/session'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
 import {
   ProgressGuideAction,
   useProgressGuideControls,
 } from '#/state/shell/progress-guide'
-import {batchedUpdates} from 'lib/batchedUpdates'
-import {HITSLOP_20} from 'lib/constants'
-import {isBlockedOrBlocking, isMuted} from 'lib/moderation/blocked-and-muted'
-import {makeProfileLink, makeStarterPackLink} from 'lib/routes/links'
-import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types'
-import {logEvent} from 'lib/statsig/statsig'
-import {getStarterPackOgCard} from 'lib/strings/starter-pack'
-import {isWeb} from 'platform/detection'
-import {updateProfileShadow} from 'state/cache/profile-shadow'
-import {useModerationOpts} from 'state/preferences/moderation-opts'
-import {getAllListMembers} from 'state/queries/list-members'
-import {useResolvedStarterPackShortLink} from 'state/queries/resolve-short-link'
-import {useResolveDidQuery} from 'state/queries/resolve-uri'
-import {useShortenLink} from 'state/queries/shorten-link'
-import {useStarterPackQuery} from 'state/queries/starter-packs'
-import {useAgent, useSession} from 'state/session'
-import {useLoggedOutViewControls} from 'state/shell/logged-out'
-import {useSetActiveStarterPack} from 'state/shell/starter-pack'
+import {useSetActiveStarterPack} from '#/state/shell/starter-pack'
+import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
+import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader'
 import * as Toast from '#/view/com/util/Toast'
-import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
-import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
-import {CenteredView} from 'view/com/util/Views'
+import {CenteredView} from '#/view/com/util/Views'
 import {bulkWriteFollows} from '#/screens/Onboarding/util'
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -591,7 +591,7 @@ function OverflowMenu({
 
               <Menu.Item
                 label={_(msg`Report starter pack`)}
-                onPress={reportDialogControl.open}>
+                onPress={() => reportDialogControl.open()}>
                 <Menu.ItemText>
                   <Trans>Report starter pack</Trans>
                 </Menu.ItemText>
diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx
index 26bb6792f..80893190f 100644
--- a/src/state/dialogs/index.tsx
+++ b/src/state/dialogs/index.tsx
@@ -1,8 +1,9 @@
 import React from 'react'
-import {SharedValue, useSharedValue} from 'react-native-reanimated'
 
+import {isWeb} from '#/platform/detection'
 import {DialogControlRefProps} from '#/components/Dialog'
 import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
+import {BottomSheet} from '../../../modules/bottom-sheet'
 
 interface IDialogContext {
   /**
@@ -16,25 +17,24 @@ interface IDialogContext {
    * `useId`.
    */
   openDialogs: React.MutableRefObject<Set<string>>
+}
+
+interface IDialogControlContext {
+  closeAllDialogs(): boolean
+  setDialogIsOpen(id: string, isOpen: boolean): void
   /**
-   * The counterpart to `accessibilityViewIsModal` for Android. This property
-   * applies to the parent of all non-modal views, and prevents TalkBack from
-   * navigating within content beneath an open dialog.
-   *
-   * @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android
+   * The number of dialogs that are fully expanded. This is used to determine the backgground color of the status bar
+   * on iOS.
    */
-  importantForAccessibility: SharedValue<'auto' | 'no-hide-descendants'>
+  fullyExpandedCount: number
+  setFullyExpandedCount: React.Dispatch<React.SetStateAction<number>>
 }
 
 const DialogContext = React.createContext<IDialogContext>({} as IDialogContext)
 
-const DialogControlContext = React.createContext<{
-  closeAllDialogs(): boolean
-  setDialogIsOpen(id: string, isOpen: boolean): void
-}>({
-  closeAllDialogs: () => false,
-  setDialogIsOpen: () => {},
-})
+const DialogControlContext = React.createContext<IDialogControlContext>(
+  {} as IDialogControlContext,
+)
 
 export function useDialogStateContext() {
   return React.useContext(DialogContext)
@@ -45,48 +45,55 @@ export function useDialogStateControlContext() {
 }
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
+  const [fullyExpandedCount, setFullyExpandedCount] = React.useState(0)
+
   const activeDialogs = React.useRef<
     Map<string, React.MutableRefObject<DialogControlRefProps>>
   >(new Map())
   const openDialogs = React.useRef<Set<string>>(new Set())
-  const importantForAccessibility = useSharedValue<
-    'auto' | 'no-hide-descendants'
-  >('auto')
 
   const closeAllDialogs = React.useCallback(() => {
-    openDialogs.current.forEach(id => {
-      const dialog = activeDialogs.current.get(id)
-      if (dialog) dialog.current.close()
-    })
-    return openDialogs.current.size > 0
+    if (isWeb) {
+      openDialogs.current.forEach(id => {
+        const dialog = activeDialogs.current.get(id)
+        if (dialog) dialog.current.close()
+      })
+
+      return openDialogs.current.size > 0
+    } else {
+      BottomSheet.dismissAll()
+      return false
+    }
   }, [])
 
-  const setDialogIsOpen = React.useCallback(
-    (id: string, isOpen: boolean) => {
-      if (isOpen) {
-        openDialogs.current.add(id)
-        importantForAccessibility.value = 'no-hide-descendants'
-      } else {
-        openDialogs.current.delete(id)
-        if (openDialogs.current.size < 1) {
-          importantForAccessibility.value = 'auto'
-        }
-      }
-    },
-    [importantForAccessibility],
-  )
+  const setDialogIsOpen = React.useCallback((id: string, isOpen: boolean) => {
+    if (isOpen) {
+      openDialogs.current.add(id)
+    } else {
+      openDialogs.current.delete(id)
+    }
+  }, [])
 
   const context = React.useMemo<IDialogContext>(
     () => ({
       activeDialogs,
       openDialogs,
-      importantForAccessibility,
     }),
-    [importantForAccessibility, activeDialogs, openDialogs],
+    [activeDialogs, openDialogs],
   )
   const controls = React.useMemo(
-    () => ({closeAllDialogs, setDialogIsOpen}),
-    [closeAllDialogs, setDialogIsOpen],
+    () => ({
+      closeAllDialogs,
+      setDialogIsOpen,
+      fullyExpandedCount,
+      setFullyExpandedCount,
+    }),
+    [
+      closeAllDialogs,
+      setDialogIsOpen,
+      fullyExpandedCount,
+      setFullyExpandedCount,
+    ],
   )
 
   return (
diff --git a/src/state/preferences/in-app-browser.tsx b/src/state/preferences/in-app-browser.tsx
index 76c854105..1494fa4e8 100644
--- a/src/state/preferences/in-app-browser.tsx
+++ b/src/state/preferences/in-app-browser.tsx
@@ -2,14 +2,14 @@ import React from 'react'
 import {Linking} from 'react-native'
 import * as WebBrowser from 'expo-web-browser'
 
-import {isNative} from '#/platform/detection'
-import * as persisted from '#/state/persisted'
-import {usePalette} from 'lib/hooks/usePalette'
+import {usePalette} from '#/lib/hooks/usePalette'
 import {
   createBskyAppAbsoluteUrl,
   isBskyRSSUrl,
   isRelativeUrl,
-} from 'lib/strings/url-helpers'
+} from '#/lib/strings/url-helpers'
+import {isNative} from '#/platform/detection'
+import * as persisted from '#/state/persisted'
 import {useModalControls} from '../modals'
 
 type StateContext = persisted.Schema['useInAppBrowser']
@@ -62,7 +62,7 @@ export function useOpenLink() {
   const pal = usePalette('default')
 
   const openLink = React.useCallback(
-    (url: string, override?: boolean) => {
+    async (url: string, override?: boolean) => {
       if (isBskyRSSUrl(url) && isRelativeUrl(url)) {
         url = createBskyAppAbsoluteUrl(url)
       }
@@ -75,7 +75,7 @@ export function useOpenLink() {
           })
           return
         } else if (override ?? enabled) {
-          WebBrowser.openBrowserAsync(url, {
+          await WebBrowser.openBrowserAsync(url, {
             presentationStyle:
               WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN,
             toolbarColor: pal.colors.backgroundLight,
diff --git a/src/style.css b/src/style.css
index 72e1ce2a2..a4c501cc1 100644
--- a/src/style.css
+++ b/src/style.css
@@ -6,21 +6,6 @@
  * may need to touch all three. Ask Eric if you aren't sure.
  */
 
-@font-face {
-  font-family: 'InterVariable';
-  src: url(/assets/fonts/inter/InterVariable.ttf) format('truetype');
-  font-weight: 300 1000;
-  font-style: normal;
-  font-display: swap;
-}
-@font-face {
-  font-family: 'InterVariableItalic';
-  src: url(/assets/fonts/inter/InterVariable-Italic.ttf) format('truetype');
-  font-weight: 300 1000;
-  font-style: italic;
-  font-display: swap;
-}
-
 /**
  * BEGIN STYLES
  *
diff --git a/src/view/com/auth/server-input/index.tsx b/src/view/com/auth/server-input/index.tsx
index fb69e1d9c..74b0d2315 100644
--- a/src/view/com/auth/server-input/index.tsx
+++ b/src/view/com/auth/server-input/index.tsx
@@ -66,12 +66,8 @@ export function ServerInputDialog({
   ])
 
   return (
-    <Dialog.Outer
-      control={control}
-      nativeOptions={{sheet: {snapPoints: ['100%']}}}
-      onClose={onClose}>
+    <Dialog.Outer control={control} onClose={onClose}>
       <Dialog.Handle />
-
       <Dialog.ScrollableInner
         accessibilityDescribedBy="dialog-description"
         accessibilityLabelledBy="dialog-title">
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index f4e290ca8..e03c64a42 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -114,11 +114,14 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {createPortalGroup} from '#/components/Portal'
 import * as Prompt from '#/components/Prompt'
 import {Text as NewText} from '#/components/Typography'
 import {composerReducer, createComposerState} from './state/composer'
 import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
 
+const Portal = createPortalGroup()
+
 const MAX_IMAGES = 4
 
 type CancelRef = {
@@ -184,13 +187,10 @@ export const ComposePost = ({
     initQuote,
   )
 
-  const [videoAltText, setVideoAltText] = useState('')
-  const [captions, setCaptions] = useState<{lang: string; file: File}[]>([])
-
   // TODO: Move more state here.
   const [composerState, dispatch] = useReducer(
     composerReducer,
-    {initImageUris},
+    {initImageUris, initQuoteUri: initQuote?.uri},
     createComposerState,
   )
 
@@ -337,6 +337,7 @@ export const ComposePost = ({
 
   const onNewLink = useCallback(
     (uri: string) => {
+      dispatch({type: 'embed_add_uri', uri})
       if (extLink != null) return
       setExtLink({uri, isLoading: true})
     },
@@ -421,10 +422,9 @@ export const ComposePost = ({
       try {
         postUri = (
           await apilib.post(agent, {
-            composerState, // TODO: not used yet.
+            composerState, // TODO: move more state here.
             rawText: richtext.text,
             replyTo: replyTo?.uri,
-            images,
             quote,
             extLink,
             labels,
@@ -432,18 +432,6 @@ export const ComposePost = ({
             postgate,
             onStateChange: setProcessingState,
             langs: toPostLanguages(langPrefs.postLanguage),
-            video:
-              videoState.status === 'done'
-                ? {
-                    blobRef: videoState.pendingPublish.blobRef,
-                    altText: videoAltText,
-                    captions: captions,
-                    aspectRatio: {
-                      width: videoState.asset.width,
-                      height: videoState.asset.height,
-                    },
-                  }
-                : undefined,
           })
         ).uri
         try {
@@ -521,7 +509,6 @@ export const ComposePost = ({
     [
       _,
       agent,
-      captions,
       composerState,
       extLink,
       images,
@@ -540,9 +527,7 @@ export const ComposePost = ({
       setExtLink,
       setLangPrefs,
       threadgateAllowUISettings,
-      videoAltText,
       videoState.asset,
-      videoState.pendingPublish,
       videoState.status,
     ],
   )
@@ -582,6 +567,7 @@ export const ComposePost = ({
 
   const onSelectGif = useCallback(
     (gif: Gif) => {
+      dispatch({type: 'embed_add_gif', gif})
       setExtLink({
         uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`,
         isLoading: true,
@@ -600,6 +586,7 @@ export const ComposePost = ({
 
   const handleChangeGifAltText = useCallback(
     (altText: string) => {
+      dispatch({type: 'embed_update_gif', alt: altText})
       setExtLink(ext =>
         ext && ext.meta
           ? {
@@ -629,268 +616,313 @@ export const ComposePost = ({
   const keyboardVerticalOffset = useKeyboardVerticalOffset()
 
   return (
-    <KeyboardAvoidingView
-      testID="composePostView"
-      behavior={isIOS ? 'padding' : 'height'}
-      keyboardVerticalOffset={keyboardVerticalOffset}
-      style={a.flex_1}>
-      <View style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal>
-        <Animated.View
-          style={topBarAnimatedStyle}
-          layout={native(LinearTransition)}>
-          <View style={styles.topbarInner}>
-            <Button
-              label={_(msg`Cancel`)}
-              variant="ghost"
-              color="primary"
-              shape="default"
-              size="small"
-              style={[
-                a.rounded_full,
-                a.py_sm,
-                {paddingLeft: 7, paddingRight: 7},
-              ]}
-              onPress={onPressCancel}
-              accessibilityHint={_(
-                msg`Closes post composer and discards post draft`,
-              )}>
-              <ButtonText style={[a.text_md]}>
-                <Trans>Cancel</Trans>
-              </ButtonText>
-            </Button>
-            <View style={a.flex_1} />
-            {isProcessing ? (
-              <>
-                <Text style={pal.textLight}>{processingState}</Text>
-                <View style={styles.postBtn}>
-                  <ActivityIndicator />
-                </View>
-              </>
-            ) : (
-              <View style={[styles.postBtnWrapper]}>
-                <LabelsBtn
-                  labels={labels}
-                  onChange={setLabels}
-                  hasMedia={hasMedia}
-                />
-                {canPost ? (
-                  <Button
-                    testID="composerPublishBtn"
-                    label={
-                      replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
-                    }
-                    variant="solid"
-                    color="primary"
-                    shape="default"
-                    size="small"
-                    style={[a.rounded_full, a.py_sm]}
-                    onPress={() => onPressPublish()}
-                    disabled={videoState.status !== 'idle' && publishOnUpload}>
-                    <ButtonText style={[a.text_md]}>
-                      {replyTo ? (
-                        <Trans context="action">Reply</Trans>
-                      ) : (
-                        <Trans context="action">Post</Trans>
-                      )}
-                    </ButtonText>
-                  </Button>
-                ) : (
-                  <View style={[styles.postBtn, pal.btn]}>
-                    <Text style={[pal.textLight, s.f16, s.bold]}>
-                      <Trans context="action">Post</Trans>
-                    </Text>
+    <Portal.Provider>
+      <KeyboardAvoidingView
+        testID="composePostView"
+        behavior={isIOS ? 'padding' : 'height'}
+        keyboardVerticalOffset={keyboardVerticalOffset}
+        style={a.flex_1}>
+        <View
+          style={[a.flex_1, viewStyles]}
+          aria-modal
+          accessibilityViewIsModal>
+          <Animated.View
+            style={topBarAnimatedStyle}
+            layout={native(LinearTransition)}>
+            <View style={styles.topbarInner}>
+              <Button
+                label={_(msg`Cancel`)}
+                variant="ghost"
+                color="primary"
+                shape="default"
+                size="small"
+                style={[
+                  a.rounded_full,
+                  a.py_sm,
+                  {paddingLeft: 7, paddingRight: 7},
+                ]}
+                onPress={onPressCancel}
+                accessibilityHint={_(
+                  msg`Closes post composer and discards post draft`,
+                )}>
+                <ButtonText style={[a.text_md]}>
+                  <Trans>Cancel</Trans>
+                </ButtonText>
+              </Button>
+              <View style={a.flex_1} />
+              {isProcessing ? (
+                <>
+                  <Text style={pal.textLight}>{processingState}</Text>
+                  <View style={styles.postBtn}>
+                    <ActivityIndicator />
                   </View>
-                )}
+                </>
+              ) : (
+                <View style={[styles.postBtnWrapper]}>
+                  <LabelsBtn
+                    labels={labels}
+                    onChange={setLabels}
+                    hasMedia={hasMedia}
+                  />
+                  {canPost ? (
+                    <Button
+                      testID="composerPublishBtn"
+                      label={
+                        replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
+                      }
+                      variant="solid"
+                      color="primary"
+                      shape="default"
+                      size="small"
+                      style={[a.rounded_full, a.py_sm]}
+                      onPress={() => onPressPublish()}
+                      disabled={
+                        videoState.status !== 'idle' && publishOnUpload
+                      }>
+                      <ButtonText style={[a.text_md]}>
+                        {replyTo ? (
+                          <Trans context="action">Reply</Trans>
+                        ) : (
+                          <Trans context="action">Post</Trans>
+                        )}
+                      </ButtonText>
+                    </Button>
+                  ) : (
+                    <View style={[styles.postBtn, pal.btn]}>
+                      <Text style={[pal.textLight, s.f16, s.bold]}>
+                        <Trans context="action">Post</Trans>
+                      </Text>
+                    </View>
+                  )}
+                </View>
+              )}
+            </View>
+
+            {isAltTextRequiredAndMissing && (
+              <View style={[styles.reminderLine, pal.viewLight]}>
+                <View style={styles.errorIcon}>
+                  <FontAwesomeIcon
+                    icon="exclamation"
+                    style={{color: colors.red4}}
+                    size={10}
+                  />
+                </View>
+                <Text style={[pal.text, a.flex_1]}>
+                  <Trans>One or more images is missing alt text.</Trans>
+                </Text>
               </View>
             )}
-          </View>
+            <ErrorBanner
+              error={error}
+              videoState={videoState}
+              clearError={() => setError('')}
+              clearVideo={clearVideo}
+            />
+          </Animated.View>
+          <Animated.ScrollView
+            layout={native(LinearTransition)}
+            onScroll={scrollHandler}
+            style={styles.scrollView}
+            keyboardShouldPersistTaps="always"
+            onContentSizeChange={onScrollViewContentSizeChange}
+            onLayout={onScrollViewLayout}>
+            {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
+
+            <View
+              style={[
+                styles.textInputLayout,
+                isNative && styles.textInputLayoutMobile,
+              ]}>
+              <UserAvatar
+                avatar={currentProfile?.avatar}
+                size={50}
+                type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
+              />
+              <TextInput
+                ref={textInput}
+                richtext={richtext}
+                placeholder={selectTextInputPlaceholder}
+                autoFocus
+                setRichText={setRichText}
+                onPhotoPasted={onPhotoPasted}
+                onPressPublish={() => onPressPublish()}
+                onNewLink={onNewLink}
+                onError={setError}
+                accessible={true}
+                accessibilityLabel={_(msg`Write post`)}
+                accessibilityHint={_(
+                  msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
+                )}
+              />
+            </View>
 
-          {isAltTextRequiredAndMissing && (
-            <View style={[styles.reminderLine, pal.viewLight]}>
-              <View style={styles.errorIcon}>
-                <FontAwesomeIcon
-                  icon="exclamation"
-                  style={{color: colors.red4}}
-                  size={10}
+            <Gallery
+              images={images}
+              dispatch={dispatch}
+              Portal={Portal.Portal}
+            />
+            {images.length === 0 && extLink && (
+              <View style={a.relative}>
+                <ExternalEmbed
+                  link={extLink}
+                  gif={extGif}
+                  onRemove={() => {
+                    if (extGif) {
+                      dispatch({type: 'embed_remove_gif'})
+                    } else {
+                      dispatch({type: 'embed_remove_link'})
+                    }
+                    setExtLink(undefined)
+                    setExtGif(undefined)
+                  }}
+                />
+                <GifAltText
+                  link={extLink}
+                  gif={extGif}
+                  onSubmit={handleChangeGifAltText}
+                  Portal={Portal.Portal}
                 />
               </View>
-              <Text style={[pal.text, a.flex_1]}>
-                <Trans>One or more images is missing alt text.</Trans>
-              </Text>
+            )}
+            <LayoutAnimationConfig skipExiting>
+              {hasVideo && (
+                <Animated.View
+                  style={[a.w_full, a.mt_lg]}
+                  entering={native(ZoomIn)}
+                  exiting={native(ZoomOut)}>
+                  {videoState.asset &&
+                    (videoState.status === 'compressing' ? (
+                      <VideoTranscodeProgress
+                        asset={videoState.asset}
+                        progress={videoState.progress}
+                        clear={clearVideo}
+                      />
+                    ) : videoState.video ? (
+                      <VideoPreview
+                        asset={videoState.asset}
+                        video={videoState.video}
+                        setDimensions={updateVideoDimensions}
+                        clear={clearVideo}
+                      />
+                    ) : null)}
+                  <SubtitleDialogBtn
+                    defaultAltText={videoState.altText}
+                    saveAltText={altText =>
+                      dispatch({
+                        type: 'embed_update_video',
+                        videoAction: {
+                          type: 'update_alt_text',
+                          altText,
+                          signal: videoState.abortController.signal,
+                        },
+                      })
+                    }
+                    captions={videoState.captions}
+                    setCaptions={updater => {
+                      dispatch({
+                        type: 'embed_update_video',
+                        videoAction: {
+                          type: 'update_captions',
+                          updater,
+                          signal: videoState.abortController.signal,
+                        },
+                      })
+                    }}
+                    Portal={Portal.Portal}
+                  />
+                </Animated.View>
+              )}
+            </LayoutAnimationConfig>
+            <View style={!hasVideo ? [a.mt_md] : []}>
+              {quote ? (
+                <View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
+                  <View style={{pointerEvents: 'none'}}>
+                    <QuoteEmbed quote={quote} />
+                  </View>
+                  {quote.uri !== initQuote?.uri && (
+                    <QuoteX
+                      onRemove={() => {
+                        dispatch({type: 'embed_remove_quote'})
+                        setQuote(undefined)
+                      }}
+                    />
+                  )}
+                </View>
+              ) : null}
             </View>
+          </Animated.ScrollView>
+          <SuggestedLanguage text={richtext.text} />
+
+          {replyTo ? null : (
+            <ThreadgateBtn
+              postgate={postgate}
+              onChangePostgate={setPostgate}
+              threadgateAllowUISettings={threadgateAllowUISettings}
+              onChangeThreadgateAllowUISettings={
+                onChangeThreadgateAllowUISettings
+              }
+              style={bottomBarAnimatedStyle}
+              Portal={Portal.Portal}
+            />
           )}
-          <ErrorBanner
-            error={error}
-            videoState={videoState}
-            clearError={() => setError('')}
-            clearVideo={clearVideo}
-          />
-        </Animated.View>
-        <Animated.ScrollView
-          layout={native(LinearTransition)}
-          onScroll={scrollHandler}
-          style={styles.scrollView}
-          keyboardShouldPersistTaps="always"
-          onContentSizeChange={onScrollViewContentSizeChange}
-          onLayout={onScrollViewLayout}>
-          {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
-
           <View
             style={[
-              styles.textInputLayout,
-              isNative && styles.textInputLayoutMobile,
+              t.atoms.bg,
+              t.atoms.border_contrast_medium,
+              styles.bottomBar,
             ]}>
-            <UserAvatar
-              avatar={currentProfile?.avatar}
-              size={50}
-              type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
-            />
-            <TextInput
-              ref={textInput}
-              richtext={richtext}
-              placeholder={selectTextInputPlaceholder}
-              autoFocus
-              setRichText={setRichText}
-              onPhotoPasted={onPhotoPasted}
-              onPressPublish={() => onPressPublish()}
-              onNewLink={onNewLink}
-              onError={setError}
-              accessible={true}
-              accessibilityLabel={_(msg`Write post`)}
-              accessibilityHint={_(
-                msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
-              )}
-            />
-          </View>
-
-          <Gallery images={images} dispatch={dispatch} />
-          {images.length === 0 && extLink && (
-            <View style={a.relative}>
-              <ExternalEmbed
-                link={extLink}
-                gif={extGif}
-                onRemove={() => {
-                  setExtLink(undefined)
-                  setExtGif(undefined)
-                }}
-              />
-              <GifAltText
-                link={extLink}
-                gif={extGif}
-                onSubmit={handleChangeGifAltText}
-              />
-            </View>
-          )}
-          <LayoutAnimationConfig skipExiting>
-            {hasVideo && (
-              <Animated.View
-                style={[a.w_full, a.mt_lg]}
-                entering={native(ZoomIn)}
-                exiting={native(ZoomOut)}>
-                {videoState.asset &&
-                  (videoState.status === 'compressing' ? (
-                    <VideoTranscodeProgress
-                      asset={videoState.asset}
-                      progress={videoState.progress}
-                      clear={clearVideo}
-                    />
-                  ) : videoState.video ? (
-                    <VideoPreview
-                      asset={videoState.asset}
-                      video={videoState.video}
-                      setDimensions={updateVideoDimensions}
-                      clear={clearVideo}
-                    />
-                  ) : null)}
-                <SubtitleDialogBtn
-                  defaultAltText={videoAltText}
-                  saveAltText={setVideoAltText}
-                  captions={captions}
-                  setCaptions={setCaptions}
+            {videoState.status !== 'idle' && videoState.status !== 'done' ? (
+              <VideoUploadToolbar state={videoState} />
+            ) : (
+              <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
+                <SelectPhotoBtn
+                  size={images.length}
+                  disabled={!canSelectImages}
+                  onAdd={onImageAdd}
+                />
+                <SelectVideoBtn
+                  onSelectVideo={selectVideo}
+                  disabled={!canSelectImages || images?.length > 0}
+                  setError={setError}
                 />
-              </Animated.View>
+                <OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
+                <SelectGifBtn
+                  onClose={focusTextInput}
+                  onSelectGif={onSelectGif}
+                  disabled={hasMedia}
+                  Portal={Portal.Portal}
+                />
+                {!isMobile ? (
+                  <Button
+                    onPress={onEmojiButtonPress}
+                    style={a.p_sm}
+                    label={_(msg`Open emoji picker`)}
+                    accessibilityHint={_(msg`Open emoji picker`)}
+                    variant="ghost"
+                    shape="round"
+                    color="primary">
+                    <EmojiSmile size="lg" />
+                  </Button>
+                ) : null}
+              </ToolbarWrapper>
             )}
-          </LayoutAnimationConfig>
-          <View style={!hasVideo ? [a.mt_md] : []}>
-            {quote ? (
-              <View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
-                <View style={{pointerEvents: 'none'}}>
-                  <QuoteEmbed quote={quote} />
-                </View>
-                {quote.uri !== initQuote?.uri && (
-                  <QuoteX onRemove={() => setQuote(undefined)} />
-                )}
-              </View>
-            ) : null}
+            <View style={a.flex_1} />
+            <SelectLangBtn />
+            <CharProgress count={graphemeLength} />
           </View>
-        </Animated.ScrollView>
-        <SuggestedLanguage text={richtext.text} />
-
-        {replyTo ? null : (
-          <ThreadgateBtn
-            postgate={postgate}
-            onChangePostgate={setPostgate}
-            threadgateAllowUISettings={threadgateAllowUISettings}
-            onChangeThreadgateAllowUISettings={
-              onChangeThreadgateAllowUISettings
-            }
-            style={bottomBarAnimatedStyle}
-          />
-        )}
-        <View
-          style={[
-            t.atoms.bg,
-            t.atoms.border_contrast_medium,
-            styles.bottomBar,
-          ]}>
-          {videoState.status !== 'idle' && videoState.status !== 'done' ? (
-            <VideoUploadToolbar state={videoState} />
-          ) : (
-            <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
-              <SelectPhotoBtn
-                size={images.length}
-                disabled={!canSelectImages}
-                onAdd={onImageAdd}
-              />
-              <SelectVideoBtn
-                onSelectVideo={selectVideo}
-                disabled={!canSelectImages || images?.length > 0}
-                setError={setError}
-              />
-              <OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
-              <SelectGifBtn
-                onClose={focusTextInput}
-                onSelectGif={onSelectGif}
-                disabled={hasMedia}
-              />
-              {!isMobile ? (
-                <Button
-                  onPress={onEmojiButtonPress}
-                  style={a.p_sm}
-                  label={_(msg`Open emoji picker`)}
-                  accessibilityHint={_(msg`Open emoji picker`)}
-                  variant="ghost"
-                  shape="round"
-                  color="primary">
-                  <EmojiSmile size="lg" />
-                </Button>
-              ) : null}
-            </ToolbarWrapper>
-          )}
-          <View style={a.flex_1} />
-          <SelectLangBtn />
-          <CharProgress count={graphemeLength} />
         </View>
-      </View>
-      <Prompt.Basic
-        control={discardPromptControl}
-        title={_(msg`Discard draft?`)}
-        description={_(msg`Are you sure you'd like to discard this draft?`)}
-        onConfirm={onClose}
-        confirmButtonCta={_(msg`Discard`)}
-        confirmButtonColor="negative"
-      />
-    </KeyboardAvoidingView>
+        <Prompt.Basic
+          control={discardPromptControl}
+          title={_(msg`Discard draft?`)}
+          description={_(msg`Are you sure you'd like to discard this draft?`)}
+          onConfirm={onClose}
+          confirmButtonCta={_(msg`Discard`)}
+          confirmButtonColor="negative"
+          Portal={Portal.Portal}
+        />
+      </KeyboardAvoidingView>
+      <Portal.Outlet />
+    </Portal.Provider>
   )
 }
 
diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx
index a05607c76..3479fb973 100644
--- a/src/view/com/composer/GifAltText.tsx
+++ b/src/view/com/composer/GifAltText.tsx
@@ -20,6 +20,7 @@ import * as Dialog from '#/components/Dialog'
 import * as TextField from '#/components/forms/TextField'
 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
 import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {PortalComponent} from '#/components/Portal'
 import {Text} from '#/components/Typography'
 import {GifEmbed} from '../util/post-embeds/GifEmbed'
 import {AltTextReminder} from './photos/Gallery'
@@ -28,10 +29,12 @@ export function GifAltText({
   link: linkProp,
   gif,
   onSubmit,
+  Portal,
 }: {
   link: ExternalEmbedDraft
   gif?: Gif
   onSubmit: (alt: string) => void
+  Portal: PortalComponent
 }) {
   const control = Dialog.useDialogControl()
   const {_} = useLingui()
@@ -96,9 +99,7 @@ export function GifAltText({
 
       <AltTextReminder />
 
-      <Dialog.Outer
-        control={control}
-        nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
+      <Dialog.Outer control={control} Portal={Portal}>
         <Dialog.Handle />
         <AltTextInner
           onSubmit={onPressSubmit}
@@ -185,6 +186,8 @@ function AltTextInner({
         </View>
       </View>
       <Dialog.Close />
+      {/* Maybe fix this later -h */}
+      {isAndroid ? <View style={{height: 300}} /> : null}
     </Dialog.ScrollableInner>
   )
 }
diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx
index 0afb83ed9..ebe528abc 100644
--- a/src/view/com/composer/photos/EditImageDialog.web.tsx
+++ b/src/view/com/composer/photos/EditImageDialog.web.tsx
@@ -20,6 +20,7 @@ import {EditImageDialogProps} from './EditImageDialog'
 export const EditImageDialog = (props: EditImageDialogProps) => {
   return (
     <Dialog.Outer control={props.control}>
+      <Dialog.Handle />
       <EditImageInner key={props.image.source.id} {...props} />
     </Dialog.Outer>
   )
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index 5ff7042bc..3958a85c0 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -21,6 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery'
 import {Text} from '#/view/com/util/text/Text'
 import {useTheme} from '#/alf'
 import * as Dialog from '#/components/Dialog'
+import {PortalComponent} from '#/components/Portal'
 import {ComposerAction} from '../state/composer'
 import {EditImageDialog} from './EditImageDialog'
 import {ImageAltTextDialog} from './ImageAltTextDialog'
@@ -30,6 +31,7 @@ const IMAGE_GAP = 8
 interface GalleryProps {
   images: ComposerImage[]
   dispatch: (action: ComposerAction) => void
+  Portal: PortalComponent
 }
 
 export let Gallery = (props: GalleryProps): React.ReactNode => {
@@ -57,7 +59,12 @@ interface GalleryInnerProps extends GalleryProps {
   containerInfo: Dimensions
 }
 
-const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
+const GalleryInner = ({
+  images,
+  containerInfo,
+  dispatch,
+  Portal,
+}: GalleryInnerProps) => {
   const {isMobile} = useWebMediaQueries()
 
   const {altTextControlStyle, imageControlsStyle, imageStyle} =
@@ -111,6 +118,7 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
               onRemove={() => {
                 dispatch({type: 'embed_remove_image', image})
               }}
+              Portal={Portal}
             />
           )
         })}
@@ -127,6 +135,7 @@ type GalleryItemProps = {
   imageStyle?: ViewStyle
   onChange: (next: ComposerImage) => void
   onRemove: () => void
+  Portal: PortalComponent
 }
 
 const GalleryItem = ({
@@ -136,6 +145,7 @@ const GalleryItem = ({
   imageStyle,
   onChange,
   onRemove,
+  Portal,
 }: GalleryItemProps): React.ReactNode => {
   const {_} = useLingui()
   const t = useTheme()
@@ -230,6 +240,7 @@ const GalleryItem = ({
         control={altTextControl}
         image={image}
         onChange={onChange}
+        Portal={Portal}
       />
 
       <EditImageDialog
diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx
index 123e1066a..16ce4351a 100644
--- a/src/view/com/composer/photos/ImageAltTextDialog.tsx
+++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx
@@ -5,25 +5,26 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {MAX_ALT_TEXT} from '#/lib/constants'
-import {isWeb} from '#/platform/detection'
+import {isAndroid, isWeb} from '#/platform/detection'
 import {ComposerImage} from '#/state/gallery'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
 import * as TextField from '#/components/forms/TextField'
+import {PortalComponent} from '#/components/Portal'
 import {Text} from '#/components/Typography'
 
 type Props = {
   control: Dialog.DialogOuterProps['control']
   image: ComposerImage
   onChange: (next: ComposerImage) => void
+  Portal: PortalComponent
 }
 
 export const ImageAltTextDialog = (props: Props): React.ReactNode => {
   return (
-    <Dialog.Outer control={props.control}>
+    <Dialog.Outer control={props.control} Portal={props.Portal}>
       <Dialog.Handle />
-
       <ImageAltTextInner {...props} />
     </Dialog.Outer>
   )
@@ -116,6 +117,8 @@ const ImageAltTextInner = ({
           </ButtonText>
         </Button>
       </View>
+      {/* Maybe fix this later -h */}
+      {isAndroid ? <View style={{height: 300}} /> : null}
     </Dialog.ScrollableInner>
   )
 }
diff --git a/src/view/com/composer/photos/SelectGifBtn.tsx b/src/view/com/composer/photos/SelectGifBtn.tsx
index d13df0a11..d482e0783 100644
--- a/src/view/com/composer/photos/SelectGifBtn.tsx
+++ b/src/view/com/composer/photos/SelectGifBtn.tsx
@@ -9,14 +9,16 @@ import {atoms as a, useTheme} from '#/alf'
 import {Button} from '#/components/Button'
 import {GifSelectDialog} from '#/components/dialogs/GifSelect'
 import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif'
+import {PortalComponent} from '#/components/Portal'
 
 type Props = {
   onClose: () => void
   onSelectGif: (gif: Gif) => void
   disabled?: boolean
+  Portal?: PortalComponent
 }
 
-export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
+export function SelectGifBtn({onClose, onSelectGif, disabled, Portal}: Props) {
   const {_} = useLingui()
   const ref = useRef<{open: () => void}>(null)
   const t = useTheme()
@@ -46,6 +48,7 @@ export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
         controlRef={ref}
         onClose={onClose}
         onSelectGif={onSelectGif}
+        Portal={Portal}
       />
     </>
   )
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index 34ead3d9a..37bfbafe6 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -9,6 +9,7 @@ import {isNative} from '#/platform/detection'
 import {ComposerImage, createComposerImage} from '#/state/gallery'
 import {atoms as a, useTheme} from '#/alf'
 import {Button} from '#/components/Button'
+import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
 import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
 
 type Props = {
@@ -21,23 +22,26 @@ export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
   const {_} = useLingui()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
   const t = useTheme()
+  const sheetWrapper = useSheetWrapper()
 
   const onPressSelectPhotos = useCallback(async () => {
     if (isNative && !(await requestPhotoAccessIfNeeded())) {
       return
     }
 
-    const images = await openPicker({
-      selectionLimit: 4 - size,
-      allowsMultipleSelection: true,
-    })
+    const images = await sheetWrapper(
+      openPicker({
+        selectionLimit: 4 - size,
+        allowsMultipleSelection: true,
+      }),
+    )
 
     const results = await Promise.all(
       images.map(img => createComposerImage(img)),
     )
 
     onAdd(results)
-  }, [requestPhotoAccessIfNeeded, size, onAdd])
+  }, [requestPhotoAccessIfNeeded, size, onAdd, sheetWrapper])
 
   return (
     <Button
diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts
index a23a5d8c8..769a0521d 100644
--- a/src/view/com/composer/state/composer.ts
+++ b/src/view/com/composer/state/composer.ts
@@ -1,17 +1,14 @@
 import {ImagePickerAsset} from 'expo-image-picker'
 
+import {isBskyPostUrl} from '#/lib/strings/url-helpers'
 import {ComposerImage, createInitialImages} from '#/state/gallery'
+import {Gif} from '#/state/queries/tenor'
 import {ComposerOpts} from '#/state/shell/composer'
 import {createVideoState, VideoAction, videoReducer, VideoState} from './video'
 
-type PostRecord = {
-  uri: string
-}
-
 type ImagesMedia = {
   type: 'images'
   images: ComposerImage[]
-  labels: string[]
 }
 
 type VideoMedia = {
@@ -19,16 +16,30 @@ type VideoMedia = {
   video: VideoState
 }
 
-type ComposerEmbed = {
-  // TODO: Other record types.
-  record: PostRecord | undefined
-  // TODO: Other media types.
-  media: ImagesMedia | VideoMedia | undefined
+type GifMedia = {
+  type: 'gif'
+  gif: Gif
+  alt: string
+}
+
+type Link = {
+  type: 'link'
+  uri: string
+}
+
+// This structure doesn't exactly correspond to the data model.
+// Instead, it maps to how the UI is organized, and how we present a post.
+type EmbedDraft = {
+  // We'll always submit quote and actual media (images, video, gifs) chosen by the user.
+  quote: Link | undefined
+  media: ImagesMedia | VideoMedia | GifMedia | undefined
+  // This field may end up ignored if we have more important things to display than a link card:
+  link: Link | undefined
 }
 
 export type ComposerState = {
   // TODO: Other draft data.
-  embed: ComposerEmbed
+  embed: EmbedDraft
 }
 
 export type ComposerAction =
@@ -42,6 +53,12 @@ export type ComposerAction =
     }
   | {type: 'embed_remove_video'}
   | {type: 'embed_update_video'; videoAction: VideoAction}
+  | {type: 'embed_add_uri'; uri: string}
+  | {type: 'embed_remove_quote'}
+  | {type: 'embed_remove_link'}
+  | {type: 'embed_add_gif'; gif: Gif}
+  | {type: 'embed_update_gif'; alt: string}
+  | {type: 'embed_remove_gif'}
 
 const MAX_IMAGES = 4
 
@@ -60,7 +77,6 @@ export function composerReducer(
         nextMedia = {
           type: 'images',
           images: action.images.slice(0, MAX_IMAGES),
-          labels: [],
         }
       } else if (prevMedia.type === 'images') {
         nextMedia = {
@@ -171,6 +187,102 @@ export function composerReducer(
         },
       }
     }
+    case 'embed_add_uri': {
+      const prevQuote = state.embed.quote
+      const prevLink = state.embed.link
+      let nextQuote = prevQuote
+      let nextLink = prevLink
+      if (isBskyPostUrl(action.uri)) {
+        if (!prevQuote) {
+          nextQuote = {
+            type: 'link',
+            uri: action.uri,
+          }
+        }
+      } else {
+        if (!prevLink) {
+          nextLink = {
+            type: 'link',
+            uri: action.uri,
+          }
+        }
+      }
+      return {
+        ...state,
+        embed: {
+          ...state.embed,
+          quote: nextQuote,
+          link: nextLink,
+        },
+      }
+    }
+    case 'embed_remove_link': {
+      return {
+        ...state,
+        embed: {
+          ...state.embed,
+          link: undefined,
+        },
+      }
+    }
+    case 'embed_remove_quote': {
+      return {
+        ...state,
+        embed: {
+          ...state.embed,
+          quote: undefined,
+        },
+      }
+    }
+    case 'embed_add_gif': {
+      const prevMedia = state.embed.media
+      let nextMedia = prevMedia
+      if (!prevMedia) {
+        nextMedia = {
+          type: 'gif',
+          gif: action.gif,
+          alt: '',
+        }
+      }
+      return {
+        ...state,
+        embed: {
+          ...state.embed,
+          media: nextMedia,
+        },
+      }
+    }
+    case 'embed_update_gif': {
+      const prevMedia = state.embed.media
+      let nextMedia = prevMedia
+      if (prevMedia?.type === 'gif') {
+        nextMedia = {
+          ...prevMedia,
+          alt: action.alt,
+        }
+      }
+      return {
+        ...state,
+        embed: {
+          ...state.embed,
+          media: nextMedia,
+        },
+      }
+    }
+    case 'embed_remove_gif': {
+      const prevMedia = state.embed.media
+      let nextMedia = prevMedia
+      if (prevMedia?.type === 'gif') {
+        nextMedia = undefined
+      }
+      return {
+        ...state,
+        embed: {
+          ...state.embed,
+          media: nextMedia,
+        },
+      }
+    }
     default:
       return state
   }
@@ -178,22 +290,31 @@ export function composerReducer(
 
 export function createComposerState({
   initImageUris,
+  initQuoteUri,
 }: {
   initImageUris: ComposerOpts['imageUris']
+  initQuoteUri: string | undefined
 }): ComposerState {
   let media: ImagesMedia | undefined
   if (initImageUris?.length) {
     media = {
       type: 'images',
       images: createInitialImages(initImageUris),
-      labels: [],
     }
   }
-  // TODO: initial video.
+  let quote: Link | undefined
+  if (initQuoteUri) {
+    quote = {
+      type: 'link',
+      uri: initQuoteUri,
+    }
+  }
+  // TODO: Other initial content.
   return {
     embed: {
-      record: undefined,
+      quote,
       media,
+      link: undefined,
     },
   }
 }
diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts
index 269505657..e29687200 100644
--- a/src/view/com/composer/state/video.ts
+++ b/src/view/com/composer/state/video.ts
@@ -4,8 +4,6 @@ import {JobStatus} from '@atproto/api/dist/client/types/app/bsky/video/defs'
 import {I18n} from '@lingui/core'
 import {msg} from '@lingui/macro'
 
-import {createVideoAgent} from '#/lib/media/video/util'
-import {uploadVideo} from '#/lib/media/video/upload'
 import {AbortError} from '#/lib/async/cancelable'
 import {compressVideo} from '#/lib/media/video/compress'
 import {
@@ -14,8 +12,12 @@ import {
   VideoTooLargeError,
 } from '#/lib/media/video/errors'
 import {CompressedVideo} from '#/lib/media/video/types'
+import {uploadVideo} from '#/lib/media/video/upload'
+import {createVideoAgent} from '#/lib/media/video/util'
 import {logger} from '#/logger'
 
+type CaptionsTrack = {lang: string; file: File}
+
 export type VideoAction =
   | {
       type: 'compressing_to_uploading'
@@ -41,6 +43,16 @@ export type VideoAction =
       signal: AbortSignal
     }
   | {
+      type: 'update_alt_text'
+      altText: string
+      signal: AbortSignal
+    }
+  | {
+      type: 'update_captions'
+      updater: (prev: CaptionsTrack[]) => CaptionsTrack[]
+      signal: AbortSignal
+    }
+  | {
       type: 'update_job_status'
       jobStatus: AppBskyVideoDefs.JobStatus
       signal: AbortSignal
@@ -57,6 +69,8 @@ export const NO_VIDEO = Object.freeze({
   video: undefined,
   jobId: undefined,
   pendingPublish: undefined,
+  altText: '',
+  captions: [],
 })
 
 export type NoVideoState = typeof NO_VIDEO
@@ -70,6 +84,8 @@ type ErrorState = {
   jobId: string | null
   error: string
   pendingPublish?: undefined
+  altText: string
+  captions: CaptionsTrack[]
 }
 
 type CompressingState = {
@@ -80,6 +96,8 @@ type CompressingState = {
   video?: undefined
   jobId?: undefined
   pendingPublish?: undefined
+  altText: string
+  captions: CaptionsTrack[]
 }
 
 type UploadingState = {
@@ -90,6 +108,8 @@ type UploadingState = {
   video: CompressedVideo
   jobId?: undefined
   pendingPublish?: undefined
+  altText: string
+  captions: CaptionsTrack[]
 }
 
 type ProcessingState = {
@@ -101,6 +121,8 @@ type ProcessingState = {
   jobId: string
   jobStatus: AppBskyVideoDefs.JobStatus | null
   pendingPublish?: undefined
+  altText: string
+  captions: CaptionsTrack[]
 }
 
 type DoneState = {
@@ -111,6 +133,8 @@ type DoneState = {
   video: CompressedVideo
   jobId?: undefined
   pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean}
+  altText: string
+  captions: CaptionsTrack[]
 }
 
 export type VideoState =
@@ -129,6 +153,8 @@ export function createVideoState(
     progress: 0,
     abortController,
     asset,
+    altText: '',
+    captions: [],
   }
 }
 
@@ -149,6 +175,8 @@ export function videoReducer(
       asset: state.asset ?? null,
       video: state.video ?? null,
       jobId: state.jobId ?? null,
+      altText: state.altText,
+      captions: state.captions,
     }
   } else if (action.type === 'update_progress') {
     if (state.status === 'compressing' || state.status === 'uploading') {
@@ -164,6 +192,16 @@ export function videoReducer(
         asset: {...state.asset, width: action.width, height: action.height},
       }
     }
+  } else if (action.type === 'update_alt_text') {
+    return {
+      ...state,
+      altText: action.altText,
+    }
+  } else if (action.type === 'update_captions') {
+    return {
+      ...state,
+      captions: action.updater(state.captions),
+    }
   } else if (action.type === 'compressing_to_uploading') {
     if (state.status === 'compressing') {
       return {
@@ -172,6 +210,8 @@ export function videoReducer(
         abortController: state.abortController,
         asset: state.asset,
         video: action.video,
+        altText: state.altText,
+        captions: state.captions,
       }
     }
     return state
@@ -185,6 +225,8 @@ export function videoReducer(
         video: state.video,
         jobId: action.jobId,
         jobStatus: null,
+        altText: state.altText,
+        captions: state.captions,
       }
     }
   } else if (action.type === 'update_job_status') {
@@ -210,6 +252,8 @@ export function videoReducer(
           blobRef: action.blobRef,
           mutableProcessed: false,
         },
+        altText: state.altText,
+        captions: state.captions,
       }
     }
   }
diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx
index b0806180c..7e57a57d4 100644
--- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx
+++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx
@@ -13,6 +13,7 @@ import * as Dialog from '#/components/Dialog'
 import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog'
 import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
 import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
+import {PortalComponent} from '#/components/Portal'
 
 export function ThreadgateBtn({
   postgate,
@@ -20,6 +21,7 @@ export function ThreadgateBtn({
   threadgateAllowUISettings,
   onChangeThreadgateAllowUISettings,
   style,
+  Portal,
 }: {
   postgate: AppBskyFeedPostgate.Record
   onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
@@ -28,6 +30,8 @@ export function ThreadgateBtn({
   onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
 
   style?: StyleProp<AnimatedStyle<ViewStyle>>
+
+  Portal: PortalComponent
 }) {
   const {_} = useLingui()
   const t = useTheme()
@@ -77,6 +81,7 @@ export function ThreadgateBtn({
         onChangePostgate={onChangePostgate}
         threadgateAllowUISettings={threadgateAllowUISettings}
         onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings}
+        Portal={Portal}
       />
     </>
   )
diff --git a/src/view/com/composer/videos/SubtitleDialog.tsx b/src/view/com/composer/videos/SubtitleDialog.tsx
index c07fdfc56..04522ee1d 100644
--- a/src/view/com/composer/videos/SubtitleDialog.tsx
+++ b/src/view/com/composer/videos/SubtitleDialog.tsx
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
 import {MAX_ALT_TEXT} from '#/lib/constants'
 import {useEnforceMaxGraphemeCount} from '#/lib/strings/helpers'
 import {LANGUAGES} from '#/locale/languages'
-import {isAndroid, isWeb} from '#/platform/detection'
+import {isWeb} from '#/platform/detection'
 import {useLanguagePrefs} from '#/state/preferences'
 import {atoms as a, useTheme, web} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -17,18 +17,20 @@ import {CC_Stroke2_Corner0_Rounded as CCIcon} from '#/components/icons/CC'
 import {PageText_Stroke2_Corner0_Rounded as PageTextIcon} from '#/components/icons/PageText'
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
+import {PortalComponent} from '#/components/Portal'
 import {Text} from '#/components/Typography'
 import {SubtitleFilePicker} from './SubtitleFilePicker'
 
 const MAX_NUM_CAPTIONS = 1
 
+type CaptionsTrack = {lang: string; file: File}
+
 interface Props {
   defaultAltText: string
-  captions: {lang: string; file: File}[]
+  captions: CaptionsTrack[]
   saveAltText: (altText: string) => void
-  setCaptions: React.Dispatch<
-    React.SetStateAction<{lang: string; file: File}[]>
-  >
+  setCaptions: (updater: (prev: CaptionsTrack[]) => CaptionsTrack[]) => void
+  Portal: PortalComponent
 }
 
 export function SubtitleDialogBtn(props: Props) {
@@ -56,9 +58,7 @@ export function SubtitleDialogBtn(props: Props) {
           {isWeb ? <Trans>Captions & alt text</Trans> : <Trans>Alt text</Trans>}
         </ButtonText>
       </Button>
-      <Dialog.Outer
-        control={control}
-        nativeOptions={isAndroid ? {sheet: {snapPoints: ['60%']}} : {}}>
+      <Dialog.Outer control={control} Portal={props.Portal}>
         <Dialog.Handle />
         <SubtitleDialogInner {...props} />
       </Dialog.Outer>
@@ -198,9 +198,7 @@ function SubtitleFileRow({
   language: string
   file: File
   otherLanguages: {code2: string; code3: string; name: string}[]
-  setCaptions: React.Dispatch<
-    React.SetStateAction<{lang: string; file: File}[]>
-  >
+  setCaptions: (updater: (prev: CaptionsTrack[]) => CaptionsTrack[]) => void
   style: StyleProp<ViewStyle>
 }) {
   const {_} = useLingui()
diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx
index 5ad4c256d..c5582922a 100644
--- a/src/view/com/post-thread/PostThreadComposePrompt.tsx
+++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx
@@ -48,7 +48,7 @@ export function PostThreadComposePrompt({
       accessibilityHint={_(msg`Opens composer`)}
       style={[
         gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11},
-        gtMobile ? {paddingLeft: 6, paddingRight: 6} : a.px_sm,
+        a.px_sm,
         a.border_t,
         t.atoms.border_contrast_low,
         t.atoms.bg,
diff --git a/src/view/com/post-thread/PostThreadFollowBtn.tsx b/src/view/com/post-thread/PostThreadFollowBtn.tsx
index b75731f6f..1808e91a3 100644
--- a/src/view/com/post-thread/PostThreadFollowBtn.tsx
+++ b/src/view/com/post-thread/PostThreadFollowBtn.tsx
@@ -1,14 +1,9 @@
 import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {AppBskyActorDefs} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 
-import {usePalette} from '#/lib/hooks/usePalette'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import {s} from '#/lib/styles'
 import {logger} from '#/logger'
 import {Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
 import {
@@ -16,8 +11,11 @@ import {
   useProfileQuery,
 } from '#/state/queries/profile'
 import {useRequireAuth} from '#/state/session'
-import {Text} from '#/view/com/util/text/Text'
 import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useBreakpoints} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
 
 export function PostThreadFollowBtn({did}: {did: string}) {
   const {data: profile, isLoading} = useProfileQuery({did})
@@ -36,9 +34,7 @@ function PostThreadFollowBtnLoaded({
 }) {
   const navigation = useNavigation()
   const {_} = useLingui()
-  const pal = usePalette('default')
-  const palInverted = usePalette('inverted')
-  const {isTabletOrDesktop} = useWebMediaQueries()
+  const {gtMobile} = useBreakpoints()
   const profile: Shadow<AppBskyActorDefs.ProfileViewBasic> =
     useProfileShadow(profileUnshadowed)
   const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
@@ -113,51 +109,32 @@ function PostThreadFollowBtnLoaded({
   if (!showFollowBtn) return null
 
   return (
-    <View style={{width: isTabletOrDesktop ? 130 : 120}}>
-      <View style={styles.btnOuter}>
-        <TouchableOpacity
-          testID="followBtn"
-          onPress={onPress}
-          style={[styles.btn, !isFollowing ? palInverted.view : pal.viewLight]}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Follow ${profile.handle}`)}
-          accessibilityHint={_(
-            msg`Shows posts from ${profile.handle} in your feed`,
-          )}>
-          {isTabletOrDesktop && (
-            <FontAwesomeIcon
-              icon={!isFollowing ? 'plus' : 'check'}
-              style={[!isFollowing ? palInverted.text : pal.text, s.mr5]}
-            />
-          )}
-          <Text
-            type="button"
-            style={[!isFollowing ? palInverted.text : pal.text, s.bold]}
-            numberOfLines={1}>
-            {!isFollowing ? (
-              isFollowedBy ? (
-                <Trans>Follow Back</Trans>
-              ) : (
-                <Trans>Follow</Trans>
-              )
-            ) : (
-              <Trans>Following</Trans>
-            )}
-          </Text>
-        </TouchableOpacity>
-      </View>
-    </View>
+    <Button
+      testID="followBtn"
+      label={_(msg`Follow ${profile.handle}`)}
+      onPress={onPress}
+      size="small"
+      variant="solid"
+      color="secondary_inverted"
+      style={[a.rounded_full]}>
+      {gtMobile && (
+        <ButtonIcon
+          icon={isFollowing ? Check : Plus}
+          position="left"
+          size="sm"
+        />
+      )}
+      <ButtonText>
+        {!isFollowing ? (
+          isFollowedBy ? (
+            <Trans>Follow Back</Trans>
+          ) : (
+            <Trans>Follow</Trans>
+          )
+        ) : (
+          <Trans>Following</Trans>
+        )}
+      </ButtonText>
+    </Button>
   )
 }
-
-const styles = StyleSheet.create({
-  btnOuter: {
-    marginLeft: 'auto',
-  },
-  btn: {
-    flexDirection: 'row',
-    borderRadius: 50,
-    paddingVertical: 8,
-    paddingHorizontal: 14,
-  },
-})
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index ead9df116..4701f225c 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -14,14 +14,12 @@ import {useLingui} from '@lingui/react'
 
 import {MAX_POST_LINES} from '#/lib/constants'
 import {usePalette} from '#/lib/hooks/usePalette'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {makeProfileLink} from '#/lib/routes/links'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {countLines} from '#/lib/strings/helpers'
 import {niceDate} from '#/lib/strings/time'
 import {s} from '#/lib/styles'
-import {isWeb} from '#/platform/detection'
 import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
 import {useLanguagePrefs} from '#/state/preferences'
 import {useOpenLink} from '#/state/preferences/in-app-browser'
@@ -30,9 +28,10 @@ import {useSession} from '#/state/session'
 import {useComposerControls} from '#/state/shell/composer'
 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
 import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
-import {atoms as a} from '#/alf'
+import {atoms as a, useTheme} from '#/alf'
 import {AppModerationCause} from '#/components/Pills'
 import {RichText} from '#/components/RichText'
+import {Text as NewText} from '#/components/Typography'
 import {ContentHider} from '../../../components/moderation/ContentHider'
 import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
 import {PostAlerts} from '../../../components/moderation/PostAlerts'
@@ -180,6 +179,7 @@ let PostThreadItemLoaded = ({
   hideTopBorder?: boolean
   threadgateRecord?: AppBskyFeedThreadgate.Record
 }): React.ReactNode => {
+  const t = useTheme()
   const pal = usePalette('default')
   const {_, i18n} = useLingui()
   const langPrefs = useLanguagePrefs()
@@ -268,8 +268,14 @@ let PostThreadItemLoaded = ({
     return (
       <>
         {rootUri !== post.uri && (
-          <View style={{paddingLeft: 16, flexDirection: 'row', height: 16}}>
-            <View style={{width: 38}}>
+          <View
+            style={[
+              a.pl_lg,
+              a.flex_row,
+              a.pb_xs,
+              {height: a.pt_lg.paddingTop},
+            ]}>
+            <View style={{width: 42}}>
               <View
                 style={[
                   styles.replyLine,
@@ -286,88 +292,74 @@ let PostThreadItemLoaded = ({
         <View
           testID={`postThreadItem-by-${post.author.handle}`}
           style={[
-            styles.outer,
-            styles.outerHighlighted,
-            pal.border,
-            pal.view,
-            rootUri === post.uri && styles.outerHighlightedRoot,
-            hideTopBorder && styles.noTopBorder,
-          ]}
-          accessible={false}>
-          <View style={[styles.layout]}>
-            <View style={[styles.layoutAvi, {paddingBottom: 8}]}>
-              <PreviewableUserAvatar
-                size={42}
-                profile={post.author}
-                moderation={moderation.ui('avatar')}
-                type={post.author.associated?.labeler ? 'labeler' : 'user'}
-              />
-            </View>
-            <View style={styles.layoutContent}>
-              <View
-                style={[styles.meta, styles.metaExpandedLine1, {zIndex: 1}]}>
-                <Link style={s.flex1} href={authorHref} title={authorTitle}>
-                  <Text
-                    emoji
-                    type="xl-bold"
-                    style={[pal.text, a.self_start]}
-                    numberOfLines={1}
-                    lineHeight={1.2}>
-                    {sanitizeDisplayName(
-                      post.author.displayName ||
-                        sanitizeHandle(post.author.handle),
-                      moderation.ui('displayName'),
-                    )}
-                  </Text>
-                </Link>
-              </View>
-              <View style={styles.meta}>
-                <Link style={s.flex1} href={authorHref} title={authorTitle}>
-                  <Text
-                    emoji
-                    type="md"
-                    style={[pal.textLight]}
-                    numberOfLines={1}>
-                    {sanitizeHandle(post.author.handle, '@')}
-                  </Text>
-                </Link>
-              </View>
+            a.px_lg,
+            t.atoms.border_contrast_low,
+            // root post styles
+            rootUri === post.uri && [a.pt_lg],
+          ]}>
+          <View style={[a.flex_row, a.gap_md, a.pb_md]}>
+            <PreviewableUserAvatar
+              size={42}
+              profile={post.author}
+              moderation={moderation.ui('avatar')}
+              type={post.author.associated?.labeler ? 'labeler' : 'user'}
+            />
+            <View style={[a.flex_1]}>
+              <Link style={s.flex1} href={authorHref} title={authorTitle}>
+                <NewText
+                  emoji
+                  style={[a.text_lg, a.font_bold, a.leading_snug, a.self_start]}
+                  numberOfLines={1}>
+                  {sanitizeDisplayName(
+                    post.author.displayName ||
+                      sanitizeHandle(post.author.handle),
+                    moderation.ui('displayName'),
+                  )}
+                </NewText>
+              </Link>
+              <Link style={s.flex1} href={authorHref} title={authorTitle}>
+                <NewText
+                  emoji
+                  style={[
+                    a.text_md,
+                    a.leading_snug,
+                    t.atoms.text_contrast_medium,
+                  ]}
+                  numberOfLines={1}>
+                  {sanitizeHandle(post.author.handle, '@')}
+                </NewText>
+              </Link>
             </View>
             {currentAccount?.did !== post.author.did && (
-              <PostThreadFollowBtn did={post.author.did} />
+              <View>
+                <PostThreadFollowBtn did={post.author.did} />
+              </View>
             )}
           </View>
-          <View style={[s.pl10, s.pr10, s.pb10]}>
-            <LabelsOnMyPost post={post} />
+          <View style={[a.pb_sm]}>
+            <LabelsOnMyPost post={post} style={[a.pb_sm]} />
             <ContentHider
               modui={moderation.ui('contentView')}
               ignoreMute
-              style={styles.contentHider}
-              childContainerStyle={styles.contentHiderChild}>
+              childContainerStyle={[a.pt_sm]}>
               <PostAlerts
                 modui={moderation.ui('contentView')}
                 size="lg"
                 includeMute
-                style={[a.pt_2xs, a.pb_sm]}
+                style={[a.pb_sm]}
                 additionalCauses={additionalPostAlerts}
               />
               {richText?.text ? (
-                <View
-                  style={[
-                    styles.postTextContainer,
-                    styles.postTextLargeContainer,
-                  ]}>
-                  <RichText
-                    enableTags
-                    selectable
-                    value={richText}
-                    style={[a.flex_1, a.text_xl]}
-                    authorHandle={post.author.handle}
-                  />
-                </View>
+                <RichText
+                  enableTags
+                  selectable
+                  value={richText}
+                  style={[a.flex_1, a.text_xl]}
+                  authorHandle={post.author.handle}
+                />
               ) : undefined}
               {post.embed && (
-                <View style={[a.pb_sm]}>
+                <View style={[a.py_xs]}>
                   <PostEmbeds
                     embed={post.embed}
                     moderation={moderation}
@@ -386,68 +378,73 @@ let PostThreadItemLoaded = ({
             post.likeCount !== 0 ||
             post.quoteCount !== 0 ? (
               // Show this section unless we're *sure* it has no engagement.
-              <View style={[styles.expandedInfo, pal.border]}>
+              <View
+                style={[
+                  a.flex_row,
+                  a.align_center,
+                  a.gap_lg,
+                  a.border_t,
+                  a.border_b,
+                  a.mt_md,
+                  a.py_md,
+                  t.atoms.border_contrast_low,
+                ]}>
                 {post.repostCount != null && post.repostCount !== 0 ? (
-                  <Link
-                    style={styles.expandedInfoItem}
-                    href={repostsHref}
-                    title={repostsTitle}>
-                    <Text
+                  <Link href={repostsHref} title={repostsTitle}>
+                    <NewText
                       testID="repostCount-expanded"
-                      type="lg"
-                      style={pal.textLight}>
-                      <Text type="xl-bold" style={pal.text}>
+                      style={[a.text_md, t.atoms.text_contrast_medium]}>
+                      <NewText style={[a.text_md, a.font_bold, t.atoms.text]}>
                         {formatCount(i18n, post.repostCount)}
-                      </Text>{' '}
+                      </NewText>{' '}
                       <Plural
                         value={post.repostCount}
                         one="repost"
                         other="reposts"
                       />
-                    </Text>
+                    </NewText>
                   </Link>
                 ) : null}
                 {post.quoteCount != null &&
                 post.quoteCount !== 0 &&
                 !post.viewer?.embeddingDisabled ? (
-                  <Link
-                    style={styles.expandedInfoItem}
-                    href={quotesHref}
-                    title={quotesTitle}>
-                    <Text
+                  <Link href={quotesHref} title={quotesTitle}>
+                    <NewText
                       testID="quoteCount-expanded"
-                      type="lg"
-                      style={pal.textLight}>
-                      <Text type="xl-bold" style={pal.text}>
+                      style={[a.text_md, t.atoms.text_contrast_medium]}>
+                      <NewText style={[a.text_md, a.font_bold, t.atoms.text]}>
                         {formatCount(i18n, post.quoteCount)}
-                      </Text>{' '}
+                      </NewText>{' '}
                       <Plural
                         value={post.quoteCount}
                         one="quote"
                         other="quotes"
                       />
-                    </Text>
+                    </NewText>
                   </Link>
                 ) : null}
                 {post.likeCount != null && post.likeCount !== 0 ? (
-                  <Link
-                    style={styles.expandedInfoItem}
-                    href={likesHref}
-                    title={likesTitle}>
-                    <Text
+                  <Link href={likesHref} title={likesTitle}>
+                    <NewText
                       testID="likeCount-expanded"
-                      type="lg"
-                      style={pal.textLight}>
-                      <Text type="xl-bold" style={pal.text}>
+                      style={[a.text_md, t.atoms.text_contrast_medium]}>
+                      <NewText style={[a.text_md, a.font_bold, t.atoms.text]}>
                         {formatCount(i18n, post.likeCount)}
-                      </Text>{' '}
+                      </NewText>{' '}
                       <Plural value={post.likeCount} one="like" other="likes" />
-                    </Text>
+                    </NewText>
                   </Link>
                 ) : null}
               </View>
             ) : null}
-            <View style={[s.pl10, s.pr10]}>
+            <View
+              style={[
+                a.pt_sm,
+                a.pb_2xs,
+                {
+                  marginLeft: -5,
+                },
+              ]}>
               <PostCtrls
                 big
                 post={post}
@@ -481,9 +478,8 @@ let PostThreadItemLoaded = ({
           testID={`postThreadItem-by-${post.author.handle}`}
           href={postHref}
           disabled={overrideBlur}
-          style={[pal.view]}
           modui={moderation.ui('contentList')}
-          iconSize={isThreadedChild ? 26 : 38}
+          iconSize={isThreadedChild ? 24 : 42}
           iconStyles={
             isThreadedChild ? {marginRight: 4} : {marginLeft: 2, marginRight: 2}
           }
@@ -496,7 +492,7 @@ let PostThreadItemLoaded = ({
               paddingLeft: 8,
               height: isThreadedChildAdjacentTop ? 8 : 16,
             }}>
-            <View style={{width: 38}}>
+            <View style={{width: 42}}>
               {!isThreadedChild && showParentReplyLine && (
                 <View
                   style={[
@@ -514,7 +510,9 @@ let PostThreadItemLoaded = ({
 
           <View
             style={[
-              styles.layout,
+              a.flex_row,
+              a.px_sm,
+              a.gap_md,
               {
                 paddingBottom:
                   showChildReplyLine && !isThreadedChild
@@ -526,9 +524,9 @@ let PostThreadItemLoaded = ({
             ]}>
             {/* If we are in threaded mode, the avatar is rendered in PostMeta */}
             {!isThreadedChild && (
-              <View style={styles.layoutAvi}>
+              <View>
                 <PreviewableUserAvatar
-                  size={38}
+                  size={42}
                   profile={post.author}
                   moderation={moderation.ui('avatar')}
                   type={post.author.associated?.labeler ? 'labeler' : 'user'}
@@ -549,12 +547,7 @@ let PostThreadItemLoaded = ({
               </View>
             )}
 
-            <View
-              style={
-                isThreadedChild
-                  ? styles.layoutContentThreaded
-                  : styles.layoutContent
-              }>
+            <View style={[a.flex_1]}>
               <PostMeta
                 author={post.author}
                 moderation={moderation}
@@ -563,20 +556,16 @@ let PostThreadItemLoaded = ({
                 showAvatar={isThreadedChild}
                 avatarModeration={moderation.ui('avatar')}
                 avatarSize={24}
-                style={
-                  isThreadedChild && {
-                    paddingBottom: isWeb ? 5 : 4,
-                  }
-                }
+                style={[a.pb_xs]}
               />
-              <LabelsOnMyPost post={post} />
+              <LabelsOnMyPost post={post} style={[a.pb_xs]} />
               <PostAlerts
                 modui={moderation.ui('contentList')}
-                style={[a.pt_2xs, a.pb_2xs]}
+                style={[a.pb_2xs]}
                 additionalCauses={additionalPostAlerts}
               />
               {richText?.text ? (
-                <View style={styles.postTextContainer}>
+                <View style={[a.pb_2xs, a.pr_sm]}>
                   <RichText
                     enableTags
                     value={richText}
@@ -659,29 +648,31 @@ function PostOuterWrapper({
   hasPrecedingItem: boolean
   hideTopBorder?: boolean
 }>) {
-  const {isMobile} = useWebMediaQueries()
-  const pal = usePalette('default')
+  const t = useTheme()
   if (treeView && depth > 0) {
     return (
       <View
         style={[
-          pal.border,
+          a.flex_row,
+          a.px_sm,
+          t.atoms.border_contrast_low,
           styles.cursor,
           {
             flexDirection: 'row',
-            paddingHorizontal: isMobile ? 10 : 6,
-            borderTopWidth: depth === 1 ? StyleSheet.hairlineWidth : 0,
+            borderTopWidth: depth === 1 ? a.border_t.borderTopWidth : 0,
           },
         ]}>
         {Array.from(Array(depth - 1)).map((_, n: number) => (
           <View
             key={`${post.uri}-padding-${n}`}
-            style={{
-              borderLeftWidth: 2,
-              borderLeftColor: pal.colors.border,
-              marginLeft: isMobile ? 6 : 12,
-              paddingLeft: isMobile ? 6 : 8,
-            }}
+            style={[
+              a.ml_sm,
+              t.atoms.border_contrast_low,
+              {
+                borderLeftWidth: 2,
+                paddingLeft: a.pl_sm.paddingLeft - 2, // minus border
+              },
+            ]}
           />
         ))}
         <View style={{flex: 1}}>{children}</View>
@@ -691,8 +682,9 @@ function PostOuterWrapper({
   return (
     <View
       style={[
-        styles.outer,
-        pal.border,
+        a.border_t,
+        a.px_sm,
+        t.atoms.border_contrast_low,
         showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
         hideTopBorder && styles.noTopBorder,
         styles.cursor,
@@ -713,6 +705,7 @@ function ExpandedPostDetails({
   needsTranslation: boolean
   translatorUrl: string
 }) {
+  const t = useTheme()
   const pal = usePalette('default')
   const {_, i18n} = useLingui()
   const openLink = useOpenLink()
@@ -723,31 +716,25 @@ function ExpandedPostDetails({
   }, [openLink, translatorUrl])
 
   return (
-    <View
-      style={[
-        a.flex_row,
-        a.align_center,
-        a.flex_wrap,
-        a.gap_xs,
-        s.mt2,
-        s.mb10,
-      ]}>
-      <Text style={[a.text_sm, pal.textLight]}>
+    <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm, a.pt_md]}>
+      <NewText style={[a.text_sm, t.atoms.text_contrast_medium]}>
         {niceDate(i18n, post.indexedAt)}
-      </Text>
+      </NewText>
       {isRootPost && (
         <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
       )}
       {needsTranslation && (
         <>
-          <Text style={[a.text_sm, pal.textLight]}>&middot;</Text>
+          <NewText style={[a.text_sm, t.atoms.text_contrast_medium]}>
+            &middot;
+          </NewText>
 
-          <Text
+          <NewText
             style={[a.text_sm, pal.link]}
             title={_(msg`Translate`)}
             onPress={onTranslatePress}>
             <Trans>Translate</Trans>
-          </Text>
+          </NewText>
         </>
       )}
     </View>
@@ -773,31 +760,9 @@ const styles = StyleSheet.create({
     borderTopWidth: StyleSheet.hairlineWidth,
     paddingLeft: 8,
   },
-  outerHighlighted: {
-    borderTopWidth: 0,
-    paddingTop: 4,
-    paddingLeft: 8,
-    paddingRight: 8,
-  },
-  outerHighlightedRoot: {
-    borderTopWidth: StyleSheet.hairlineWidth,
-    paddingTop: 16,
-  },
   noTopBorder: {
     borderTopWidth: 0,
   },
-  layout: {
-    flexDirection: 'row',
-    paddingHorizontal: 8,
-  },
-  layoutAvi: {},
-  layoutContent: {
-    flex: 1,
-    marginLeft: 10,
-  },
-  layoutContentThreaded: {
-    flex: 1,
-  },
   meta: {
     flexDirection: 'row',
     paddingVertical: 2,
@@ -805,42 +770,6 @@ const styles = StyleSheet.create({
   metaExpandedLine1: {
     paddingVertical: 0,
   },
-  alert: {
-    marginBottom: 6,
-  },
-  postTextContainer: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    flexWrap: 'wrap',
-    paddingBottom: 4,
-    paddingRight: 10,
-    overflow: 'hidden',
-  },
-  postTextLargeContainer: {
-    paddingHorizontal: 0,
-    paddingRight: 0,
-    paddingBottom: 10,
-  },
-  translateLink: {
-    marginBottom: 6,
-  },
-  contentHider: {
-    marginBottom: 6,
-  },
-  contentHiderChild: {
-    marginTop: 6,
-  },
-  expandedInfo: {
-    flexDirection: 'row',
-    padding: 10,
-    borderTopWidth: StyleSheet.hairlineWidth,
-    borderBottomWidth: StyleSheet.hairlineWidth,
-    marginTop: 5,
-    marginBottom: 10,
-  },
-  expandedInfoItem: {
-    marginRight: 10,
-  },
   loadMore: {
     flexDirection: 'row',
     alignItems: 'center',
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 2b4376b69..43555ccb4 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -20,6 +20,7 @@ import {isAndroid, isNative, isWeb} from '#/platform/detection'
 import {precacheProfile} from '#/state/queries/profile'
 import {HighPriorityImage} from '#/view/com/util/images/Image'
 import {tokens, useTheme} from '#/alf'
+import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
 import {
   Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
   Camera_Stroke2_Corner0_Rounded as Camera,
@@ -271,6 +272,7 @@ let EditableUserAvatar = ({
   const {_} = useLingui()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
+  const sheetWrapper = useSheetWrapper()
 
   const aviStyle = useMemo(() => {
     if (type === 'algo' || type === 'list') {
@@ -306,9 +308,11 @@ let EditableUserAvatar = ({
       return
     }
 
-    const items = await openPicker({
-      aspect: [1, 1],
-    })
+    const items = await sheetWrapper(
+      openPicker({
+        aspect: [1, 1],
+      }),
+    )
     const item = items[0]
     if (!item) {
       return
@@ -332,7 +336,7 @@ let EditableUserAvatar = ({
         logger.error('Failed to crop banner', {error: e})
       }
     }
-  }, [onSelectNewAvatar, requestPhotoAccessIfNeeded])
+  }, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper])
 
   const onRemoveAvatar = React.useCallback(() => {
     onSelectNewAvatar(null)
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 13f4081fc..622cb2129 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -17,6 +17,7 @@ import {logger} from '#/logger'
 import {isAndroid, isNative} from '#/platform/detection'
 import {EventStopper} from '#/view/com/util/EventStopper'
 import {tokens, useTheme as useAlfTheme} from '#/alf'
+import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
 import {
   Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
   Camera_Stroke2_Corner0_Rounded as Camera,
@@ -43,6 +44,7 @@ export function UserBanner({
   const {_} = useLingui()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
+  const sheetWrapper = useSheetWrapper()
 
   const onOpenCamera = React.useCallback(async () => {
     if (!(await requestCameraAccessIfNeeded())) {
@@ -60,7 +62,7 @@ export function UserBanner({
     if (!(await requestPhotoAccessIfNeeded())) {
       return
     }
-    const items = await openPicker()
+    const items = await sheetWrapper(openPicker())
     if (!items[0]) {
       return
     }
@@ -80,7 +82,7 @@ export function UserBanner({
         logger.error('Failed to crop banner', {error: e})
       }
     }
-  }, [onSelectNewBanner, requestPhotoAccessIfNeeded])
+  }, [onSelectNewBanner, requestPhotoAccessIfNeeded, sheetWrapper])
 
   const onRemoveBanner = React.useCallback(() => {
     onSelectNewBanner?.(null)
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index 64fa504eb..1d4cf8ff0 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -100,7 +100,7 @@ export function ViewHeader({
               </TouchableOpacity>
             ) : null}
             <View style={styles.titleContainer} pointerEvents="none">
-              <Text type="title" style={[pal.text, styles.title]}>
+              <Text emoji type="title" style={[pal.text, styles.title]}>
                 {title}
               </Text>
             </View>
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 33287564a..cd1f2d3de 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -240,8 +240,8 @@ let PostDropdownBtn = ({
     Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
   }, [_, richText])
 
-  const onPressTranslate = React.useCallback(() => {
-    openLink(translatorUrl)
+  const onPressTranslate = React.useCallback(async () => {
+    await openLink(translatorUrl)
   }, [openLink, translatorUrl])
 
   const onHidePost = React.useCallback(() => {
@@ -439,7 +439,7 @@ let PostDropdownBtn = ({
               <Menu.Item
                 testID="postDropdownSendViaDMBtn"
                 label={_(msg`Send via direct message`)}
-                onPress={sendViaChatControl.open}>
+                onPress={() => sendViaChatControl.open()}>
                 <Menu.ItemText>
                   <Trans>Send via direct message</Trans>
                 </Menu.ItemText>
@@ -467,7 +467,7 @@ let PostDropdownBtn = ({
               <Menu.Item
                 testID="postDropdownEmbedBtn"
                 label={_(msg`Embed post`)}
-                onPress={embedPostControl.open}>
+                onPress={() => embedPostControl.open()}>
                 <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
                 <Menu.ItemIcon icon={CodeBrackets} position="right" />
               </Menu.Item>
@@ -542,7 +542,7 @@ let PostDropdownBtn = ({
                           ? _(msg`Hide reply for me`)
                           : _(msg`Hide post for me`)
                       }
-                      onPress={hidePromptControl.open}>
+                      onPress={() => hidePromptControl.open()}>
                       <Menu.ItemText>
                         {isReply
                           ? _(msg`Hide reply for me`)
@@ -630,7 +630,9 @@ let PostDropdownBtn = ({
                     <Menu.Item
                       testID="postDropdownEditPostInteractions"
                       label={_(msg`Edit interaction settings`)}
-                      onPress={postInteractionSettingsDialogControl.open}
+                      onPress={() =>
+                        postInteractionSettingsDialogControl.open()
+                      }
                       {...(isAuthor
                         ? Platform.select({
                             web: {
@@ -649,7 +651,7 @@ let PostDropdownBtn = ({
                     <Menu.Item
                       testID="postDropdownDeleteBtn"
                       label={_(msg`Delete post`)}
-                      onPress={deletePromptControl.open}>
+                      onPress={() => deletePromptControl.open()}>
                       <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
                       <Menu.ItemIcon icon={Trash} position="right" />
                     </Menu.Item>
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index 0ecdf25b9..9be72ae23 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -86,7 +86,9 @@ let RepostButton = ({
           </Text>
         ) : undefined}
       </Button>
-      <Dialog.Outer control={dialogControl}>
+      <Dialog.Outer
+        control={dialogControl}
+        nativeOptions={{preventExpansion: true}}>
         <Dialog.Handle />
         <Dialog.Inner label={_(msg`Repost or quote post`)}>
           <View style={a.gap_xl}>
@@ -155,7 +157,6 @@ let RepostButton = ({
             </View>
             <Button
               label={_(msg`Cancel quote post`)}
-              onAccessibilityEscape={close}
               onPress={close}
               size="large"
               variant="solid"
diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx
index 3d885480c..42ea79b8f 100644
--- a/src/view/com/util/text/Text.tsx
+++ b/src/view/com/util/text/Text.tsx
@@ -77,13 +77,16 @@ export function Text({
       flattened.fontSize = flattened.fontSize * fonts.scaleMultiplier
     }
 
+    const shared = {
+      uiTextView: true,
+      selectable,
+      style: flattened,
+      ...props,
+    }
+
     return (
-      <UITextView
-        style={flattened}
-        selectable={selectable}
-        uiTextView
-        {...props}>
-        {isIOS && emoji ? renderChildrenWithEmoji(children) : children}
+      <UITextView {...shared}>
+        {isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children}
       </UITextView>
     )
   }
@@ -104,14 +107,16 @@ export function Text({
     flattened.fontSize = flattened.fontSize * fonts.scaleMultiplier
   }
 
+  const shared = {
+    selectable,
+    style: flattened,
+    dataSet: Object.assign({tooltip: title}, dataSet || {}),
+    ...props,
+  }
+
   return (
-    <RNText
-      style={flattened}
-      // @ts-ignore web only -esb
-      dataSet={Object.assign({tooltip: title}, dataSet || {})}
-      selectable={selectable}
-      {...props}>
-      {isIOS && emoji ? renderChildrenWithEmoji(children) : children}
+    <RNText {...shared}>
+      {isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children}
     </RNText>
   )
 }
diff --git a/src/view/screens/Settings/DisableEmail2FADialog.tsx b/src/view/screens/Settings/DisableEmail2FADialog.tsx
index a27cff9a3..e4341fcd2 100644
--- a/src/view/screens/Settings/DisableEmail2FADialog.tsx
+++ b/src/view/screens/Settings/DisableEmail2FADialog.tsx
@@ -79,7 +79,6 @@ export function DisableEmail2FADialog({
   return (
     <Dialog.Outer control={control}>
       <Dialog.Handle />
-
       <Dialog.ScrollableInner
         accessibilityDescribedBy="dialog-description"
         accessibilityLabelledBy="dialog-title">
diff --git a/src/view/screens/Settings/ExportCarDialog.tsx b/src/view/screens/Settings/ExportCarDialog.tsx
index a6ddb3820..1d8d26471 100644
--- a/src/view/screens/Settings/ExportCarDialog.tsx
+++ b/src/view/screens/Settings/ExportCarDialog.tsx
@@ -53,7 +53,6 @@ export function ExportCarDialog({
   return (
     <Dialog.Outer control={control}>
       <Dialog.Handle />
-
       <Dialog.ScrollableInner
         accessibilityDescribedBy="dialog-description"
         accessibilityLabelledBy="dialog-title">
diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx
index 3a9f67de8..a0a2a2755 100644
--- a/src/view/screens/Storybook/Dialogs.tsx
+++ b/src/view/screens/Storybook/Dialogs.tsx
@@ -2,8 +2,8 @@ import React from 'react'
 import {View} from 'react-native'
 import {useNavigation} from '@react-navigation/native'
 
+import {NavigationProp} from '#/lib/routes/types'
 import {useDialogStateControlContext} from '#/state/dialogs'
-import {NavigationProp} from 'lib/routes/types'
 import {atoms as a} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
@@ -179,19 +179,13 @@ export function Dialogs() {
       </Prompt.Outer>
 
       <Dialog.Outer control={basic}>
-        <Dialog.Handle />
-
         <Dialog.Inner label="test">
           <H3 nativeID="dialog-title">Dialog</H3>
           <P nativeID="dialog-description">A basic dialog</P>
         </Dialog.Inner>
       </Dialog.Outer>
 
-      <Dialog.Outer
-        control={scrollable}
-        nativeOptions={{sheet: {snapPoints: ['100%']}}}>
-        <Dialog.Handle />
-
+      <Dialog.Outer control={scrollable}>
         <Dialog.ScrollableInner
           accessibilityDescribedBy="dialog-description"
           accessibilityLabelledBy="dialog-title">
@@ -230,8 +224,6 @@ export function Dialogs() {
       </Dialog.Outer>
 
       <Dialog.Outer control={testDialog}>
-        <Dialog.Handle />
-
         <Dialog.ScrollableInner
           accessibilityDescribedBy="dialog-description"
           accessibilityLabelledBy="dialog-title">
@@ -356,8 +348,6 @@ export function Dialogs() {
 
       {shouldRenderUnmountTest && (
         <Dialog.Outer control={unmountTestDialog}>
-          <Dialog.Handle />
-
           <Dialog.Inner label="test">
             <H3 nativeID="dialog-title">Unmount Test Dialog</H3>
             <P nativeID="dialog-description">Will unmount in about 5 seconds</P>
diff --git a/src/view/shell/Composer.ios.tsx b/src/view/shell/Composer.ios.tsx
index 18410bf39..02efad878 100644
--- a/src/view/shell/Composer.ios.tsx
+++ b/src/view/shell/Composer.ios.tsx
@@ -1,19 +1,28 @@
-import React, {useLayoutEffect} from 'react'
+import React from 'react'
 import {Modal, View} from 'react-native'
-import {StatusBar} from 'expo-status-bar'
-import * as SystemUI from 'expo-system-ui'
 
+import {useDialogStateControlContext} from '#/state/dialogs'
 import {useComposerState} from '#/state/shell/composer'
 import {atoms as a, useTheme} from '#/alf'
-import {getBackgroundColor, useThemeName} from '#/alf/util/useColorModeTheme'
 import {ComposePost, useComposerCancelRef} from '../com/composer/Composer'
 
 export function Composer({}: {winHeight: number}) {
+  const {setFullyExpandedCount} = useDialogStateControlContext()
   const t = useTheme()
   const state = useComposerState()
   const ref = useComposerCancelRef()
 
   const open = !!state
+  const prevOpen = React.useRef(open)
+
+  React.useEffect(() => {
+    if (open && !prevOpen.current) {
+      setFullyExpandedCount(c => c + 1)
+    } else if (!open && prevOpen.current) {
+      setFullyExpandedCount(c => c - 1)
+    }
+    prevOpen.current = open
+  }, [open, setFullyExpandedCount])
 
   return (
     <Modal
@@ -24,56 +33,18 @@ export function Composer({}: {winHeight: number}) {
       animationType="slide"
       onRequestClose={() => ref.current?.onPressCancel()}>
       <View style={[t.atoms.bg, a.flex_1]}>
-        <Providers open={open}>
-          <ComposePost
-            cancelRef={ref}
-            replyTo={state?.replyTo}
-            onPost={state?.onPost}
-            quote={state?.quote}
-            quoteCount={state?.quoteCount}
-            mention={state?.mention}
-            text={state?.text}
-            imageUris={state?.imageUris}
-            videoUri={state?.videoUri}
-          />
-        </Providers>
+        <ComposePost
+          cancelRef={ref}
+          replyTo={state?.replyTo}
+          onPost={state?.onPost}
+          quote={state?.quote}
+          quoteCount={state?.quoteCount}
+          mention={state?.mention}
+          text={state?.text}
+          imageUris={state?.imageUris}
+          videoUri={state?.videoUri}
+        />
       </View>
     </Modal>
   )
 }
-
-function Providers({
-  children,
-  open,
-}: {
-  children: React.ReactNode
-  open: boolean
-}) {
-  // on iOS, it's a native formSheet. We use FullWindowOverlay to make
-  // the dialogs appear over it
-  return (
-    <>
-      {children}
-      <IOSModalBackground active={open} />
-    </>
-  )
-}
-
-// Generally, the backdrop of the app is the theme color, but when this is open
-// we want it to be black due to the modal being a form sheet.
-function IOSModalBackground({active}: {active: boolean}) {
-  const theme = useThemeName()
-
-  useLayoutEffect(() => {
-    SystemUI.setBackgroundColorAsync('black')
-
-    return () => {
-      SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
-    }
-  }, [theme])
-
-  // Set the status bar to light - however, only if the modal is active
-  // If we rely on this component being mounted to set this,
-  // there'll be a delay before it switches back to default.
-  return active ? <StatusBar style="light" animated /> : null
-}
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index aed92cbb7..8bc3de24d 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -13,6 +13,14 @@ import * as NavigationBar from 'expo-navigation-bar'
 import {StatusBar} from 'expo-status-bar'
 import {useNavigation, useNavigationState} from '@react-navigation/native'
 
+import {useDedupe} from '#/lib/hooks/useDedupe'
+import {useNotificationsHandler} from '#/lib/hooks/useNotificationHandler'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useNotificationsRegistration} from '#/lib/notifications/notifications'
+import {isStateAtTabRoot} from '#/lib/routes/helpers'
+import {useTheme} from '#/lib/ThemeContext'
+import {isAndroid, isIOS} from '#/platform/detection'
+import {useDialogStateControlContext} from '#/state/dialogs'
 import {useSession} from '#/state/session'
 import {
   useIsDrawerOpen,
@@ -20,17 +28,9 @@ import {
   useSetDrawerOpen,
 } from '#/state/shell'
 import {useCloseAnyActiveElement} from '#/state/util'
-import {useDedupe} from 'lib/hooks/useDedupe'
-import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useNotificationsRegistration} from 'lib/notifications/notifications'
-import {isStateAtTabRoot} from 'lib/routes/helpers'
-import {useTheme} from 'lib/ThemeContext'
-import {isAndroid} from 'platform/detection'
-import {useDialogStateContext} from 'state/dialogs'
-import {Lightbox} from 'view/com/lightbox/Lightbox'
-import {ModalsContainer} from 'view/com/modals/Modal'
-import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
+import {Lightbox} from '#/view/com/lightbox/Lightbox'
+import {ModalsContainer} from '#/view/com/modals/Modal'
+import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
 import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
 import {SigninDialog} from '#/components/dialogs/Signin'
 import {Outlet as PortalOutlet} from '#/components/Portal'
@@ -61,7 +61,6 @@ function ShellInner() {
   const canGoBack = useNavigationState(state => !isStateAtTabRoot(state))
   const {hasSession} = useSession()
   const closeAnyActiveElement = useCloseAnyActiveElement()
-  const {importantForAccessibility} = useDialogStateContext()
 
   useNotificationsRegistration()
   useNotificationsHandler()
@@ -101,9 +100,7 @@ function ShellInner() {
 
   return (
     <>
-      <Animated.View
-        style={containerPadding}
-        importantForAccessibility={importantForAccessibility}>
+      <Animated.View style={containerPadding}>
         <ErrorBoundary>
           <Drawer
             renderDrawerContent={renderDrawerContent}
@@ -127,6 +124,7 @@ function ShellInner() {
 }
 
 export const Shell: React.FC = function ShellImpl() {
+  const {fullyExpandedCount} = useDialogStateControlContext()
   const pal = usePalette('default')
   const theme = useTheme()
   React.useEffect(() => {
@@ -140,7 +138,14 @@ export const Shell: React.FC = function ShellImpl() {
   }, [theme])
   return (
     <View testID="mobileShellView" style={[styles.outerContainer, pal.view]}>
-      <StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} />
+      <StatusBar
+        style={
+          theme.colorScheme === 'dark' || (isIOS && fullyExpandedCount > 0)
+            ? 'light'
+            : 'dark'
+        }
+        animated
+      />
       <RoutesContainer>
         <ShellInner />
       </RoutesContainer>
diff --git a/web/index.html b/web/index.html
index 8efc2cb68..2a406429c 100644
--- a/web/index.html
+++ b/web/index.html
@@ -29,6 +29,20 @@
        *
        * THIS NEEDS TO BE DUPLICATED IN `bskyweb/templates/base.html`
        */
+      @font-face {
+        font-family: 'InterVariable';
+        src: url(/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf) format('truetype');
+        font-weight: 300 1000;
+        font-style: normal;
+        font-display: swap;
+      }
+      @font-face {
+        font-family: 'InterVariableItalic';
+        src: url(/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf) format('truetype');
+        font-weight: 300 1000;
+        font-style: italic;
+        font-display: swap;
+      }
       html {
         background-color: white;
         scrollbar-gutter: stable both-edges;
diff --git a/yarn.lock b/yarn.lock
index a7ef1e0d6..3bb82371f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11450,14 +11450,6 @@ expo-structured-headers@~3.8.0:
   resolved "https://registry.yarnpkg.com/expo-structured-headers/-/expo-structured-headers-3.8.0.tgz#11797a4c3a7a6770b21126cecffcda148030e361"
   integrity sha512-R+gFGn0x5CWl4OVlk2j1bJTJIz4KO8mPoCHpRHmfqMjmrMvrOM0qQSY3V5NHXwp1yT/L2v8aUmFQsBRIdvi1XA==
 
-expo-system-ui@~3.0.4:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/expo-system-ui/-/expo-system-ui-3.0.4.tgz#5ace49d38eb03c09a8041b3b82c581a6b974741a"
-  integrity sha512-v1n6hBO30k9qw6RE8/au4yNoovs71ExGuXizJUlR5KSo4Ruogpb+0/2q3uRZMDIYWWCANvms8L0UOh6fQJ5TXg==
-  dependencies:
-    "@react-native/normalize-colors" "~0.74.83"
-    debug "^4.3.2"
-
 expo-task-manager@~11.8.1:
   version "11.8.1"
   resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-11.8.1.tgz#33089e78ee3fbd83327fb403bce12d69baf7d21b"
@@ -18057,15 +18049,14 @@ react-native-drawer-layout@^4.0.0-alpha.3:
   dependencies:
     use-latest-callback "^0.1.9"
 
-react-native-gesture-handler@~2.16.2:
-  version "2.16.2"
-  resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.16.2.tgz#032bd2a07334292d7f6cff1dc9d1ec928f72e26d"
-  integrity sha512-vGFlrDKlmyI+BT+FemqVxmvO7nqxU33cgXVsn6IKAFishvlG3oV2Ds67D5nPkHMea8T+s1IcuMm0bF8ntZtAyg==
+react-native-gesture-handler@2.20.0:
+  version "2.20.0"
+  resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.20.0.tgz#2d9ec4e9bd22619ebe36269dda3ecb1173928276"
+  integrity sha512-rFKqgHRfxQ7uSAivk8vxCiW4SB3G0U7jnv7kZD4Y90K5kp6YrU8Q3tWhxe3Rx55BIvSd3mBe9ZWbWVJ0FsSHPA==
   dependencies:
     "@egjs/hammerjs" "^2.0.17"
     hoist-non-react-statics "^3.3.0"
     invariant "^2.2.4"
-    lodash "^4.17.21"
     prop-types "^15.7.2"
 
 react-native-get-random-values@^1.6.0:
@@ -18094,10 +18085,10 @@ react-native-ios-context-menu@^1.15.3:
   dependencies:
     "@dominicstop/ts-event-emitter" "^1.1.0"
 
-react-native-keyboard-controller@^1.12.1:
-  version "1.12.1"
-  resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.1.tgz#6de22ed4d060528a0dd25621eeaa7f71772ce35f"
-  integrity sha512-2OpQcesiYsMilrTzgcTafSGexd9UryRQRuHudIcOn0YaqvvzNpnhVZMVuJMH93fJv/iaZYp3138rgUKOdHhtSw==
+react-native-keyboard-controller@^1.14.0:
+  version "1.14.0"
+  resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.14.0.tgz#f6faaa12b3736a10f4eec4236ed5b0343508b9a1"
+  integrity sha512-JW9k2fehFXOpvLWh1YcgyubLodg/HPi6bR11sCZB/BOawf1tnbGnqk967B8XkxDOKHH6mg+z82quCvv8ALh1rg==
 
 react-native-mmkv@^2.12.2:
   version "2.12.2"