diff options
author | Hailey <me@haileyok.com> | 2024-10-04 13:24:12 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-10-04 13:24:12 -0700 |
commit | 00486e94991f344353ffb083dd631283a84c3ad3 (patch) | |
tree | a5dc4da5e5e71912d73a099e84761517fa8c62a9 | |
parent | 9802ebe20d32dc1867a069dc377b3d4c43ce45f0 (diff) | |
download | voidsky-00486e94991f344353ffb083dd631283a84c3ad3.tar.zst |
[Sheets] [Pt. 1] Root PR (#5557)
Co-authored-by: Samuel Newman <mozzius@protonmail.com> Co-authored-by: Eric Bailey <git@esb.lol> Co-authored-by: dan <dan.abramov@gmail.com> Co-authored-by: Hailey <me@haileyok.com>
86 files changed, 2461 insertions, 1529 deletions
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/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/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/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/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/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 227964907..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 = { @@ -613,296 +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> - )} - </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 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> - )} - <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`, - )} + {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> + )} + <ErrorBanner + error={error} + videoState={videoState} + clearError={() => setError('')} + clearVideo={clearVideo} /> - </View> - - <Gallery images={images} dispatch={dispatch} /> - {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) - }} + </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'} /> - <GifAltText - link={extLink} - gif={extGif} - onSubmit={handleChangeGifAltText} + <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> - )} - <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, - }, - }) + + <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) }} /> - </Animated.View> + <GifAltText + link={extLink} + gif={extGif} + onSubmit={handleChangeGifAltText} + Portal={Portal.Portal} + /> + </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) + <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} /> - )} - </View> - ) : null} - </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> + </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} - </ToolbarWrapper> + </View> + </Animated.ScrollView> + <SuggestedLanguage text={richtext.text} /> + + {replyTo ? null : ( + <ThreadgateBtn + postgate={postgate} + onChangePostgate={setPostgate} + threadgateAllowUISettings={threadgateAllowUISettings} + onChangeThreadgateAllowUISettings={ + onChangeThreadgateAllowUISettings + } + style={bottomBarAnimatedStyle} + Portal={Portal.Portal} + /> )} - <View style={a.flex_1} /> - <SelectLangBtn /> - <CharProgress count={graphemeLength} /> + <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} + 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> + )} + <View style={a.flex_1} /> + <SelectLangBtn /> + <CharProgress count={graphemeLength} /> + </View> </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/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 f66684d4e..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,6 +17,7 @@ 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' @@ -29,6 +30,7 @@ interface Props { captions: CaptionsTrack[] saveAltText: (altText: string) => void 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> 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/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/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/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" |