diff --git a/modules/bottom-sheet/android/build.gradle b/modules/bottom-sheet/android/build.gradle
new file mode 100644
index 0000000000..555d34ed1f
--- /dev/null
+++ b/modules/bottom-sheet/android/build.gradle
@@ -0,0 +1,56 @@
+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
+ }
+ buildFeatures {
+ compose true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.8"
+ }
+}
+
+dependencies {
+ implementation project(':expo-modules-core')
+ implementation "androidx.compose.material3:material3:1.3.0"
+ implementation "androidx.compose.material:material:1.7.2"
+ 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 0000000000..bdae66c8f5
--- /dev/null
+++ b/modules/bottom-sheet/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BlueskyBottomSheetView.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BlueskyBottomSheetView.kt
new file mode 100644
index 0000000000..13dc402a07
--- /dev/null
+++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BlueskyBottomSheetView.kt
@@ -0,0 +1,133 @@
+package expo.modules.bottomsheet
+
+import android.content.Context
+import android.view.View
+import android.widget.FrameLayout
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.platform.ComposeView
+import androidx.core.view.allViews
+import com.facebook.react.ReactRootView
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.viewevent.EventDispatcher
+import expo.modules.kotlin.views.ExpoView
+
+class BottomSheetView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
+ val sheetState = mutableStateOf(SheetState())
+
+ private var reactRootView: ReactRootView? = null
+ private var innerView: View? = null
+
+ private var sheetView: ComposeView? = null
+
+ private val onStateChange by EventDispatcher()
+ private val onAttemptDismiss by EventDispatcher()
+
+ // Props
+ var preventDismiss = false
+ var minHeight = 0f
+ var maxHeight = 0f
+
+ private var isOpen: Boolean = false
+ set(value) {
+ if (field == value) return
+
+ field = value
+ this.sheetState.value.isOpen = 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 hasInitiallyOpened = false
+
+ // Lifecycle
+
+ override fun addView(child: View?, index: Int) {
+ this.innerView = child
+ }
+
+ override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
+ this.innerView?.let {
+ val height = it.allViews.last().measuredHeight.toFloat()
+ this.present(height)
+ }
+ }
+
+ private fun destroy() {
+ this.isClosing = false
+ this.isOpen = false
+
+ this.getRootLayout().removeView(this.sheetView)
+ this.sheetView = null
+
+ this.reactRootView = null
+ this.innerView = null
+ }
+
+ // Presentation
+
+ private fun present(contentHeight: Float) {
+ val innerView = this.innerView ?: return
+
+ // For GestureRootView to work, we need to create a ReactRootView for the innerView to be
+ // contained inside of
+ val reactRootView = ReactRootView(context)
+ reactRootView.addView(innerView)
+ this.reactRootView = reactRootView
+
+ this.isOpening = true
+ this.sheetView = ComposeView(context).also {
+ it.setContent {
+ SheetView(
+ state = sheetState,
+ innerView = reactRootView,
+ contentHeight = innerView.height.toFloat(),
+ onDismissRequest = {
+ onAttemptDismiss(mapOf())
+ if (!preventDismiss) {
+ dismiss()
+ }
+ },
+ onExpanded = {
+ isOpening = false
+ isOpen = true
+ hasInitiallyOpened = true
+ },
+ onHidden = {
+ if (hasInitiallyOpened) {
+ destroy()
+ }
+ }
+ )
+ }
+ getRootLayout().addView(it)
+ }
+ }
+
+ fun dismiss() {
+ this.isClosing = true
+ this.destroy()
+ }
+
+ // Utils
+
+ private fun getRootLayout(): FrameLayout {
+ return appContext.currentActivity!!.findViewById(android.R.id.content)
+ }
+}
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 0000000000..d087301975
--- /dev/null
+++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt
@@ -0,0 +1,50 @@
+package expo.modules.bottomsheet
+
+import android.graphics.Color
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class BottomSheetModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("BlueskyBottomSheet")
+
+ Function("getSafeAreaInset") {
+ return@Function 10 // @TODO
+ }
+
+ View(BottomSheetView::class) {
+ Events(arrayOf(
+ "onStateChange",
+ "onAttemptDismiss",
+ ))
+
+ AsyncFunction("dismiss") { view: BottomSheetView ->
+ view.dismiss()
+ }
+
+ Prop("preventDismiss") { view: BottomSheetView, prop: Boolean ->
+ view.preventDismiss = prop
+ }
+
+ Prop("minHeight") { view: BottomSheetView, prop: Float ->
+ view.minHeight = prop
+ }
+
+ Prop("maxHeight") { view: BottomSheetView, prop: Float ->
+ view.maxHeight = prop
+ }
+
+ Prop("cornerRadius") { view: BottomSheetView, prop: Float ->
+ view.sheetState.value.cornerRadius = prop
+ }
+
+ Prop("containerBackgroundColor") { view: BottomSheetView, prop: String ->
+ view.sheetState.value.containerBackgroundColor = Color.parseColor(prop)
+ }
+
+ Prop("preventExpansion") { view: BottomSheetView, prop: Boolean ->
+ view.sheetState.value.preventExpansion = prop
+ }
+ }
+ }
+}
diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetView.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetView.kt
new file mode 100644
index 0000000000..8e5a37541f
--- /dev/null
+++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetView.kt
@@ -0,0 +1,68 @@
+package expo.modules.bottomsheet
+
+import android.view.View
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.SheetValue
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+
+data class SheetState(
+ var isOpen: Boolean = false,
+ var cornerRadius: Float? = null,
+ var containerBackgroundColor: Int ? = null,
+ var preventExpansion: Boolean = false,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SheetView(
+ state: MutableState,
+ innerView: View,
+ contentHeight: Float,
+ onDismissRequest: () -> Unit,
+ onExpanded: () -> Unit,
+ onHidden: () -> Unit,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
+
+ ModalBottomSheet(
+ sheetState = sheetState,
+ onDismissRequest = onDismissRequest,
+ shape = RoundedCornerShape(
+ topStart = state.value.cornerRadius ?: 0f,
+ topEnd = state.value.cornerRadius ?: 0f,
+ ),
+ containerColor = Color(state.value.containerBackgroundColor ?: android.graphics.Color.TRANSPARENT),
+ ) {
+ Column(
+ Modifier.fillMaxWidth()
+ .height(contentHeight.dp)
+ // Prevent covering up the handle
+ .padding(top = 34.dp)
+ ) {
+ AndroidView(
+ factory = { innerView }
+ )
+ }
+ }
+
+ LaunchedEffect(sheetState.currentValue) {
+ if (sheetState.currentValue == SheetValue.PartiallyExpanded || sheetState.currentValue == SheetValue.Expanded) {
+ onExpanded()
+ } else if (sheetState.currentValue == SheetValue.Hidden) {
+ onHidden()
+ }
+ }
+}
diff --git a/modules/bottom-sheet/ios/BottomSheet.podspec b/modules/bottom-sheet/ios/BottomSheet.podspec
new file mode 100644
index 0000000000..a42356f614
--- /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 0000000000..2b4498adf7
--- /dev/null
+++ b/modules/bottom-sheet/ios/BottomSheetModule.swift
@@ -0,0 +1,46 @@
+import ExpoModulesCore
+
+public class BottomSheetModule: Module {
+ public func definition() -> ModuleDefinition {
+ Name("BottomSheet")
+
+ AsyncFunction("dismissAll") {
+ SheetManager.shared.dismissAll()
+ }
+
+ View(SheetView.self) {
+ Events([
+ "onStateChange",
+ "onAttemptDismiss"
+ ])
+
+ 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("preventDismiss") { (view: SheetView, prop: Bool) in
+ view.preventDismiss = prop
+ }
+
+ Prop("minHeight") { (view: SheetView, prop: Double) in
+ view.minHeight = prop
+ }
+
+ Prop("maxHeight") { (view: SheetView, prop: Double) in
+ view.maxHeight = 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 0000000000..c46954c033
--- /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(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 0000000000..ccf7fcb23d
--- /dev/null
+++ b/modules/bottom-sheet/ios/SheetView.swift
@@ -0,0 +1,161 @@
+import ExpoModulesCore
+import UIKit
+
+class SheetView: ExpoView, UISheetPresentationControllerDelegate {
+ // Views
+ private var sheetVc: SheetViewController?
+ private var innerView: UIView?
+
+ // Events
+ private let onStateChange = EventDispatcher()
+ private let onAttemptDismiss = 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"
+ ])
+ }
+ }
+ }
+
+ // MARK: - Lifecycle
+
+ required init (appContext: AppContext? = nil) {
+ super.init(appContext: appContext)
+ self.maxHeight = Util.getScreenHeight()
+ 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.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(contentHeight: innerView.subviews[0].frame.size.height)
+ }
+
+ private func destroy() {
+ self.isClosing = false
+ self.isOpen = false
+ self.sheetVc = nil
+ self.innerView = nil
+ SheetManager.shared.remove(self)
+ }
+
+ // MARK: - Presentation
+
+ func present(contentHeight: CGFloat) {
+ guard !self.isOpen,
+ let innerView = self.innerView,
+ let rvc = self.reactViewController() else {
+ return
+ }
+
+ let sheetVc = SheetViewController()
+ if let sheet = sheetVc.sheetPresentationController {
+ sheet.delegate = self
+ sheet.preferredCornerRadius = self.cornerRadius
+ }
+ sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion)
+ 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[0].frame.size.height {
+ self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight),
+ preventExpansion: self.preventExpansion)
+ }
+ }
+
+ 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()
+ }
+}
diff --git a/modules/bottom-sheet/ios/SheetViewController.swift b/modules/bottom-sheet/ios/SheetViewController.swift
new file mode 100644
index 0000000000..9caa5bab14
--- /dev/null
+++ b/modules/bottom-sheet/ios/SheetViewController.swift
@@ -0,0 +1,67 @@
+//
+// 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 = true
+ }
+ }
+
+ func setDetents(contentHeight: CGFloat, preventExpansion: Bool) {
+ guard let sheet = self.sheetPresentationController,
+ let screenHeight = Util.getScreenHeight()
+ else {
+ return
+ }
+
+ if contentHeight > screenHeight {
+ sheet.detents = [
+ .large()
+ ]
+ } else {
+ if #available(iOS 16.0, *) {
+ sheet.detents = [
+ .custom { _ in
+ return contentHeight
+ }
+ ]
+ } else {
+ sheet.detents = [
+ .medium()
+ ]
+ }
+ }
+
+ if !preventExpansion {
+ sheet.detents.append(.large())
+ }
+ }
+
+ 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()
+ }
+ }
+ }
+ }
+
+ 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 0000000000..c654596a74
--- /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.types.ts b/modules/bottom-sheet/src/BottomSheet.types.ts
new file mode 100644
index 0000000000..8177df5787
--- /dev/null
+++ b/modules/bottom-sheet/src/BottomSheet.types.ts
@@ -0,0 +1,22 @@
+import React from 'react'
+import {ColorValue, NativeSyntheticEvent} from 'react-native'
+
+export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening'
+
+export interface BottomSheetViewProps {
+ children: React.ReactNode
+ cornerRadius?: number
+ preventDismiss?: boolean
+ preventExpansion?: boolean
+ containerBackgroundColor?: ColorValue
+ topInset?: number
+ bottomInset?: number
+
+ minHeight?: number
+ maxHeight?: number
+
+ onStateChange?: (
+ event: NativeSyntheticEvent<{state: BottomSheetState}>,
+ ) => void
+ onAttemptDismiss?: (event: NativeSyntheticEvent