[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>
This commit is contained in:
Hailey
2024-10-04 13:24:12 -07:00
committed by GitHub
parent 9802ebe20d
commit 00486e9499
86 changed files with 2509 additions and 1577 deletions
+5
View File
@@ -96,6 +96,11 @@ jest.mock('expo-modules-core', () => ({
getIsReducedMotionEnabled: () => false, getIsReducedMotionEnabled: () => false,
} }
} }
if (moduleName === 'BottomSheet') {
return {
dismissAll: () => {},
}
}
}), }),
requireNativeViewManager: jest.fn().mockImplementation(moduleName => { requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
return () => null return () => null
+49
View File
@@ -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:+"
}
@@ -0,0 +1,2 @@
<manifest>
</manifest>
@@ -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
}
}
}
}
@@ -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
}
@@ -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
}
}
@@ -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()
}
}
}
}
@@ -0,0 +1,9 @@
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["BottomSheetModule"]
},
"android": {
"modules": ["expo.modules.bottomsheet.BottomSheetModule"]
}
}
+13
View File
@@ -0,0 +1,13 @@
import {BottomSheet} from './src/BottomSheet'
import {
BottomSheetSnapPoint,
BottomSheetState,
BottomSheetViewProps,
} from './src/BottomSheet.types'
export {
BottomSheet,
BottomSheetSnapPoint,
type BottomSheetState,
type BottomSheetViewProps,
}
@@ -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
@@ -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
}
}
}
}
@@ -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()
}
}
}
+189
View File
@@ -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
}
}
@@ -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")
}
}
+18
View File
@@ -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
}
}
+100
View File
@@ -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>
)
}
}
@@ -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
}
@@ -0,0 +1,5 @@
import {BottomSheetViewProps} from './BottomSheet.types'
export function BottomSheet(_: BottomSheetViewProps) {
throw new Error('BottomSheetView is not available on web')
}
+2 -3
View File
@@ -137,7 +137,6 @@
"expo-sharing": "^12.0.1", "expo-sharing": "^12.0.1",
"expo-splash-screen": "~0.27.4", "expo-splash-screen": "~0.27.4",
"expo-status-bar": "~1.12.1", "expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1", "expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14", "expo-updates": "~0.25.14",
"expo-web-browser": "~13.0.3", "expo-web-browser": "~13.0.3",
@@ -171,11 +170,11 @@
"react-native-compressor": "^1.8.24", "react-native-compressor": "^1.8.24",
"react-native-date-picker": "^4.4.2", "react-native-date-picker": "^4.4.2",
"react-native-drawer-layout": "^4.0.0-alpha.3", "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-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "0.41.2", "react-native-image-crop-picker": "0.41.2",
"react-native-ios-context-menu": "^1.15.3", "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-mmkv": "^2.12.2",
"react-native-pager-view": "6.2.3", "react-native-pager-view": "6.2.3",
"react-native-picker-select": "^9.1.3", "react-native-picker-select": "^9.1.3",
+2 -2
View File
@@ -1,6 +1,6 @@
import 'react-native-url-polyfill/auto' import 'react-native-url-polyfill/auto'
import 'lib/sentry' // must be near top import '#/lib/sentry' // must be near top
import 'view/icons' import '#/view/icons'
import React, {useEffect, useState} from 'react' import React, {useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler' import {GestureHandlerRootView} from 'react-native-gesture-handler'
+2 -4
View File
@@ -1,9 +1,8 @@
import React from 'react' import React from 'react'
import {ColorSchemeName, useColorScheme} from 'react-native' import {ColorSchemeName, useColorScheme} from 'react-native'
import * as SystemUI from 'expo-system-ui'
import {isWeb} from 'platform/detection' import {isWeb} from '#/platform/detection'
import {useThemePrefs} from 'state/shell' import {useThemePrefs} from '#/state/shell'
import {dark, dim, light} from '#/alf/themes' import {dark, dim, light} from '#/alf/themes'
import {ThemeName} from '#/alf/types' import {ThemeName} from '#/alf/types'
@@ -12,7 +11,6 @@ export function useColorModeTheme(): ThemeName {
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
updateDocument(theme) updateDocument(theme)
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
}, [theme]) }, [theme])
return theme return theme
+5 -2
View File
@@ -87,6 +87,7 @@ export type ButtonProps = Pick<
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
hoverStyle?: StyleProp<ViewStyle> hoverStyle?: StyleProp<ViewStyle>
children: NonTextElements | ((context: ButtonContext) => NonTextElements) children: NonTextElements | ((context: ButtonContext) => NonTextElements)
PressableComponent?: React.ComponentType<PressableProps>
} }
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
@@ -114,6 +115,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
disabled = false, disabled = false,
style, style,
hoverStyle: hoverStyleProp, hoverStyle: hoverStyleProp,
PressableComponent = Pressable,
...rest ...rest
}, },
ref, ref,
@@ -449,10 +451,11 @@ export const Button = React.forwardRef<View, ButtonProps>(
const flattenedBaseStyles = flatten([baseStyles, style]) const flattenedBaseStyles = flatten([baseStyles, style])
return ( return (
<Pressable <PressableComponent
role="button" role="button"
accessibilityHint={undefined} // optional accessibilityHint={undefined} // optional
{...rest} {...rest}
// @ts-ignore - this will always be a pressable
ref={ref} ref={ref}
aria-label={label} aria-label={label}
aria-pressed={state.pressed} aria-pressed={state.pressed}
@@ -500,7 +503,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
<Context.Provider value={context}> <Context.Provider value={context}>
{typeof children === 'function' ? children(context) : children} {typeof children === 'function' ? children(context) : children}
</Context.Provider> </Context.Provider>
</Pressable> </PressableComponent>
) )
}, },
) )
+5
View File
@@ -6,9 +6,14 @@ import {
DialogControlRefProps, DialogControlRefProps,
DialogOuterProps, DialogOuterProps,
} from '#/components/Dialog/types' } from '#/components/Dialog/types'
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
export const Context = React.createContext<DialogContextProps>({ export const Context = React.createContext<DialogContextProps>({
close: () => {}, close: () => {},
isNativeDialog: false,
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
disableDrag: false,
setDisableDrag: () => {},
}) })
export function useDialogContext() { export function useDialogContext() {
+173 -207
View File
@@ -1,86 +1,48 @@
import React, {useImperativeHandle} from 'react' import React, {useImperativeHandle} from 'react'
import { import {
Dimensions, NativeScrollEvent,
Keyboard, NativeSyntheticEvent,
Pressable, Pressable,
ScrollView,
StyleProp, StyleProp,
TextInput,
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } 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 {useSafeAreaInsets} from 'react-native-safe-area-context'
import BottomSheet, { import {msg} from '@lingui/macro'
BottomSheetBackdropProps, import {useLingui} from '@lingui/react'
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 {logger} from '#/logger' import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs' import {useDialogStateControlContext} from '#/state/dialogs'
import {atoms as a, flatten, useTheme} from '#/alf' import {List, ListMethods, ListProps} from '#/view/com/util/List'
import {Context} from '#/components/Dialog/context' import {atoms as a, useTheme} from '#/alf'
import {Context, useDialogContext} from '#/components/Dialog/context'
import { import {
DialogControlProps, DialogControlProps,
DialogInnerProps, DialogInnerProps,
DialogOuterProps, DialogOuterProps,
} from '#/components/Dialog/types' } from '#/components/Dialog/types'
import {createInput} from '#/components/forms/TextField' import {createInput} from '#/components/forms/TextField'
import {FullWindowOverlay} from '#/components/FullWindowOverlay' import {Portal as DefaultPortal} from '#/components/Portal'
import {Portal} 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 {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/types' export * from '#/components/Dialog/types'
export * from '#/components/Dialog/utils' export * from '#/components/Dialog/utils'
// @ts-ignore // @ts-ignore
export const Input = createInput(BottomSheetTextInput) export const Input = createInput(TextInput)
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 function Outer({ export function Outer({
children, children,
@@ -88,24 +50,22 @@ export function Outer({
onClose, onClose,
nativeOptions, nativeOptions,
testID, testID,
Portal = DefaultPortal,
}: React.PropsWithChildren<DialogOuterProps>) { }: React.PropsWithChildren<DialogOuterProps>) {
const t = useTheme() const t = useTheme()
const sheet = React.useRef<BottomSheet>(null) const ref = React.useRef<BottomSheet>(null)
const sheetOptions = nativeOptions?.sheet || {}
const hasSnapPoints = !!sheetOptions.snapPoints
const insets = useSafeAreaInsets()
const closeCallbacks = React.useRef<(() => void)[]>([]) const closeCallbacks = React.useRef<(() => void)[]>([])
const {setDialogIsOpen} = useDialogStateControlContext() const {setDialogIsOpen, setFullyExpandedCount} =
useDialogStateControlContext()
/* const prevSnapPoint = React.useRef<BottomSheetSnapPoint>(
* Used to manage open/closed, but index is otherwise handled internally by `BottomSheet` BottomSheetSnapPoint.Hidden,
*/ )
const [openIndex, setOpenIndex] = React.useState(-1)
/* const [disableDrag, setDisableDrag] = React.useState(false)
* `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open. const [snapPoint, setSnapPoint] = React.useState<BottomSheetSnapPoint>(
*/ BottomSheetSnapPoint.Partial,
const isOpen = openIndex > -1 )
const callQueuedCallbacks = React.useCallback(() => { const callQueuedCallbacks = React.useCallback(() => {
for (const cb of closeCallbacks.current) { for (const cb of closeCallbacks.current) {
@@ -119,25 +79,19 @@ export function Outer({
closeCallbacks.current = [] closeCallbacks.current = []
}, []) }, [])
const open = React.useCallback<DialogControlProps['open']>( const open = React.useCallback<DialogControlProps['open']>(() => {
({index} = {}) => { // Run any leftover callbacks that might have been queued up before calling `.open()`
// Run any leftover callbacks that might have been queued up before calling `.open()` callQueuedCallbacks()
callQueuedCallbacks() setDialogIsOpen(control.id, true)
ref.current?.present()
setDialogIsOpen(control.id, true) }, [setDialogIsOpen, control.id, callQueuedCallbacks])
// 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],
)
// This is the function that we call when we want to dismiss the dialog. // This is the function that we call when we want to dismiss the dialog.
const close = React.useCallback<DialogControlProps['close']>(cb => { const close = React.useCallback<DialogControlProps['close']>(cb => {
if (typeof cb === 'function') { if (typeof cb === 'function') {
closeCallbacks.current.push(cb) 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 // 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 // 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 // tells us that we need to toggle the accessibility overlay setting
setDialogIsOpen(control.id, false) setDialogIsOpen(control.id, false)
setOpenIndex(-1)
callQueuedCallbacks() callQueuedCallbacks()
onClose?.() onClose?.()
}, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen]) }, [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( useImperativeHandle(
control.ref, control.ref,
() => ({ () => ({
@@ -161,159 +142,144 @@ export function Outer({
[open, close], [open, close],
) )
React.useEffect(() => { const context = React.useMemo(
return () => { () => ({
setDialogIsOpen(control.id, false) close,
} isNativeDialog: true,
}, [control.id, setDialogIsOpen]) nativeSnapPoint: snapPoint,
disableDrag,
const context = React.useMemo(() => ({close}), [close]) setDisableDrag,
}),
[close, snapPoint, disableDrag, setDisableDrag],
)
return ( return (
isOpen && ( <Portal>
<Portal> <Context.Provider value={context}>
<FullWindowOverlay> <BottomSheet
<View ref={ref}
// iOS cornerRadius={20}
accessibilityViewIsModal backgroundColor={t.atoms.bg.backgroundColor}
// Android {...nativeOptions}
importantForAccessibility="yes" onSnapPointChange={onSnapPointChange}
style={[a.absolute, a.inset_0]} onStateChange={onStateChange}
testID={testID} disableDrag={disableDrag}>
onTouchMove={() => Keyboard.dismiss()}> <View testID={testID}>{children}</View>
<BottomSheet </BottomSheet>
enableDynamicSizing={!hasSnapPoints} </Context.Provider>
enablePanDownToClose </Portal>
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>
)
) )
} }
export function Inner({children, style}: DialogInnerProps) { export function Inner({children, style}: DialogInnerProps) {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
return ( return (
<BottomSheetView <View
style={[ style={[
a.py_xl, a.pt_2xl,
a.px_xl, a.px_xl,
{ {
paddingTop: 40, paddingBottom: insets.bottom + insets.top,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
}, },
flatten(style), style,
]}> ]}>
{children} {children}
</BottomSheetView> </View>
) )
} }
export const ScrollableInner = React.forwardRef< export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
BottomSheetScrollViewMethods, function ScrollableInner({children, style, ...props}, ref) {
DialogInnerProps const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
>(function ScrollableInner({children, style}, ref) { const insets = useSafeAreaInsets()
const insets = useSafeAreaInsets() const [keyboardHeight, setKeyboardHeight] = React.useState(0)
return ( useKeyboardHandler({
<BottomSheetScrollView onEnd: e => {
keyboardShouldPersistTaps="handled" 'worklet'
style={[ runOnJS(setKeyboardHeight)(e.height)
a.flex_1, // main diff is this },
a.p_xl, })
a.h_full,
{ const basePading =
paddingTop: 40, (isIOS ? 30 : 50) + (isIOS ? keyboardHeight / 4 : keyboardHeight)
borderTopLeftRadius: 40, const fullPaddingBase = insets.bottom + insets.top + basePading
borderTopRightRadius: 40, const fullPadding = isIOS ? fullPaddingBase : fullPaddingBase + 50
},
style, const paddingBottom =
]} nativeSnapPoint === BottomSheetSnapPoint.Full ? fullPadding : basePading
contentContainerStyle={a.pb_4xl}
ref={ref}> const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
{children} const {contentOffset} = e.nativeEvent
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} /> if (contentOffset.y > 0 && !disableDrag) {
</BottomSheetScrollView> 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< export const InnerFlatList = React.forwardRef<
BottomSheetFlatListMethods, ListMethods,
BottomSheetFlatListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>} ListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
>(function InnerFlatList({style, contentContainerStyle, ...props}, ref) { >(function InnerFlatList({style, ...props}, ref) {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const {nativeSnapPoint} = useDialogContext()
return ( return (
<BottomSheetFlatList <List
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
contentContainerStyle={[a.pb_4xl, flatten(contentContainerStyle)]} bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
ListFooterComponent={ ListFooterComponent={
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} /> <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
} }
ref={ref} ref={ref}
{...props} {...props}
style={[ style={[style]}
a.flex_1,
a.p_xl,
a.pt_0,
a.h_full,
{
marginTop: 40,
},
flatten(style),
]}
/> />
) )
}) })
export function Handle() { export function Handle() {
const t = useTheme() const t = useTheme()
const {_} = useLingui()
const {screenReaderEnabled} = useA11y()
const {close} = useDialogContext()
return ( return (
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}> <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 20}]}>
<View <Pressable
style={[ accessible={screenReaderEnabled}
a.rounded_sm, onPress={() => close()}
{ accessibilityLabel={_(msg`Dismiss`)}
top: a.pt_lg.paddingTop, accessibilityHint={_(msg`Double tap to close the dialog`)}>
width: 35, <View
height: 4, style={[
alignSelf: 'center', a.rounded_sm,
backgroundColor: t.palette.contrast_900, {
opacity: 0.5, top: 10,
}, width: 35,
]} height: 5,
/> alignSelf: 'center',
backgroundColor: t.palette.contrast_975,
opacity: 0.5,
},
]}
/>
</Pressable>
</View> </View>
) )
} }
+8 -4
View File
@@ -103,6 +103,10 @@ export function Outer({
const context = React.useMemo( const context = React.useMemo(
() => ({ () => ({
close, close,
isNativeDialog: false,
nativeSnapPoint: 0,
disableDrag: false,
setDisableDrag: () => {},
}), }),
[close], [close],
) )
@@ -229,10 +233,6 @@ export const InnerFlatList = React.forwardRef<
) )
}) })
export function Handle() {
return null
}
export function Close() { export function Close() {
const {_} = useLingui() const {_} = useLingui()
const {close} = React.useContext(Context) const {close} = React.useContext(Context)
@@ -258,3 +258,7 @@ export function Close() {
</View> </View>
) )
} }
export function Handle() {
return null
}
+20
View File
@@ -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],
)
}
+9 -4
View File
@@ -4,9 +4,11 @@ import type {
GestureResponderEvent, GestureResponderEvent,
ScrollViewProps, ScrollViewProps,
} from 'react-native' } from 'react-native'
import {BottomSheetProps} from '@discord/bottom-sheet/src'
import {ViewStyleProp} from '#/alf' 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> type A11yProps = Required<AccessibilityProps>
@@ -37,6 +39,10 @@ export type DialogControlProps = DialogControlRefProps & {
export type DialogContextProps = { export type DialogContextProps = {
close: DialogControlProps['close'] close: DialogControlProps['close']
isNativeDialog: boolean
nativeSnapPoint: BottomSheetSnapPoint
disableDrag: boolean
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
} }
export type DialogControlOpenOptions = { export type DialogControlOpenOptions = {
@@ -52,11 +58,10 @@ export type DialogControlOpenOptions = {
export type DialogOuterProps = { export type DialogOuterProps = {
control: DialogControlProps control: DialogControlProps
onClose?: () => void onClose?: () => void
nativeOptions?: { nativeOptions?: Omit<BottomSheetViewProps, 'children'>
sheet?: Omit<BottomSheetProps, 'children'>
}
webOptions?: {} webOptions?: {}
testID?: string testID?: string
Portal?: PortalComponent
} }
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
@@ -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} />
}
@@ -1,7 +0,0 @@
export function KeyboardControllerPadding({
maxHeight: _,
}: {
maxHeight?: number
}) {
return null
}
+8 -10
View File
@@ -1,20 +1,19 @@
import React, {useMemo, useCallback} from 'react' import React, {useCallback, useMemo} from 'react'
import {ActivityIndicator, FlatList, View} from 'react-native' import {ActivityIndicator, FlatList, View} from 'react-native'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {atoms as a, useTheme} from '#/alf' import {useResolveUriQuery} from '#/state/queries/resolve-uri'
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 {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
interface LikesDialogProps { interface LikesDialogProps {
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
@@ -25,7 +24,6 @@ export function LikesDialog(props: LikesDialogProps) {
return ( return (
<Dialog.Outer control={props.control}> <Dialog.Outer control={props.control}>
<Dialog.Handle /> <Dialog.Handle />
<LikesDialogInner {...props} /> <LikesDialogInner {...props} />
</Dialog.Outer> </Dialog.Outer>
) )
+3 -3
View File
@@ -103,17 +103,17 @@ export function useLink({
linkRequiresWarning(href, displayText), linkRequiresWarning(href, displayText),
) )
if (requiresWarning) { if (isWeb) {
e.preventDefault() e.preventDefault()
}
if (requiresWarning) {
openModal({ openModal({
name: 'link-warning', name: 'link-warning',
text: displayText, text: displayText,
href: href, href: href,
}) })
} else { } else {
e.preventDefault()
if (isExternal) { if (isExternal) {
openLink(href) openLink(href)
} else { } else {
+11 -10
View File
@@ -4,7 +4,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import flattenReactChildren from 'react-keyed-flatten-children' 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 {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
@@ -82,19 +82,21 @@ export function Outer({
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}>) { }>) {
const context = React.useContext(Context) const context = React.useContext(Context)
const {_} = useLingui()
return ( return (
<Dialog.Outer control={context.control}> <Dialog.Outer
control={context.control}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle /> <Dialog.Handle />
{/* Re-wrap with context since Dialogs are portal-ed to root */} {/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}> <Context.Provider value={context}>
<Dialog.ScrollableInner label="Menu TODO"> <Dialog.ScrollableInner label={_(msg`Menu`)} style={[a.pt_sm]}>
<View style={[a.gap_lg]}> <View style={[a.gap_lg]}>
{children} {children}
{isNative && showCancel && <Cancel />} {isNative && showCancel && <Cancel />}
<View style={[{height: a.pb_lg.paddingBottom}]} />
</View> </View>
<View style={{height: a.gap_lg.gap}} />
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
</Context.Provider> </Context.Provider>
</Dialog.Outer> </Dialog.Outer>
@@ -116,15 +118,14 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
{...rest} {...rest}
accessibilityHint="" accessibilityHint=""
accessibilityLabel={label} accessibilityLabel={label}
onPress={e => { onFocus={onFocus}
onPress(e) onBlur={onBlur}
onPress={async e => {
await onPress(e)
if (!e.defaultPrevented) { if (!e.defaultPrevented) {
control?.close() control?.close()
} }
}} }}
onFocus={onFocus}
onBlur={onBlur}
onPressIn={e => { onPressIn={e => {
onPressIn() onPressIn()
rest.onPressIn?.(e) rest.onPressIn?.(e)
+3 -3
View File
@@ -5,12 +5,12 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns' import {differenceInSeconds} from 'date-fns'
import {HITSLOP_10} from '#/lib/constants'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {HITSLOP_10} from 'lib/constants' import {useSession} from '#/state/session'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {useSession} from 'state/session'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
+2
View File
@@ -12,6 +12,8 @@ type ComponentMap = {
[id: string]: Component [id: string]: Component
} }
export type PortalComponent = ({children}: {children?: React.ReactNode}) => null
export function createPortalGroup() { export function createPortalGroup() {
const Context = React.createContext<ContextType>({ const Context = React.createContext<ContextType>({
outlet: null, outlet: null,
+11 -7
View File
@@ -4,8 +4,9 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' 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 * as Dialog from '#/components/Dialog'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export { export {
@@ -25,9 +26,11 @@ export function Outer({
children, children,
control, control,
testID, testID,
Portal,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
testID?: string testID?: string
Portal?: PortalComponent
}>) { }>) {
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const titleId = React.useId() const titleId = React.useId()
@@ -39,10 +42,9 @@ export function Outer({
) )
return ( return (
<Dialog.Outer control={control} testID={testID}> <Dialog.Outer control={control} testID={testID} Portal={Portal}>
<Dialog.Handle />
<Context.Provider value={context}> <Context.Provider value={context}>
<Dialog.Handle />
<Dialog.ScrollableInner <Dialog.ScrollableInner
accessibilityLabelledBy={titleId} accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId} accessibilityDescribedBy={descriptionId}
@@ -141,7 +143,7 @@ export function Action({
* Note: The dialog will close automatically when the action is pressed, you * Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method. * should NOT close the dialog as a side effect of this method.
*/ */
onPress: ButtonProps['onPress'] onPress: (e: GestureResponderEvent) => void
color?: ButtonColor color?: ButtonColor
/** /**
* Optional i18n string. If undefined, it will default to "Confirm". * Optional i18n string. If undefined, it will default to "Confirm".
@@ -181,6 +183,7 @@ export function Basic({
onConfirm, onConfirm,
confirmButtonColor, confirmButtonColor,
showCancel = true, showCancel = true,
Portal,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
title: string title: string
@@ -194,12 +197,13 @@ export function Basic({
* Note: The dialog will close automatically when the action is pressed, you * Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method. * should NOT close the dialog as a side effect of this method.
*/ */
onConfirm: ButtonProps['onPress'] onConfirm: (e: GestureResponderEvent) => void
confirmButtonColor?: ButtonColor confirmButtonColor?: ButtonColor
showCancel?: boolean showCancel?: boolean
Portal?: PortalComponent
}>) { }>) {
return ( return (
<Outer control={control} testID="confirmModal"> <Outer control={control} testID="confirmModal" Portal={Portal}>
<TitleText>{title}</TitleText> <TitleText>{title}</TitleText>
<DescriptionText>{description}</DescriptionText> <DescriptionText>{description}</DescriptionText>
<Actions> <Actions>
@@ -4,7 +4,6 @@ import {AppBskyLabelerDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {getLabelingServiceTitle} from '#/lib/moderation' import {getLabelingServiceTitle} from '#/lib/moderation'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, useButtonContext} from '#/components/Button' import {Button, useButtonContext} from '#/components/Button'
@@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react'
import {getLabelingServiceTitle} from '#/lib/moderation' import {getLabelingServiceTitle} from '#/lib/moderation'
import {ReportOption} from '#/lib/moderation/useReportOptions' import {ReportOption} from '#/lib/moderation/useReportOptions'
import {isAndroid} from '#/platform/detection'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
@@ -225,6 +226,8 @@ export function SubmitView({
{submitting && <ButtonIcon icon={Loader} />} {submitting && <ButtonIcon icon={Loader} />}
</Button> </Button>
</View> </View>
{/* Maybe fix this later -h */}
{isAndroid ? <View style={{height: 300}} /> : null}
</View> </View>
) )
} }
+2 -7
View File
@@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -8,12 +9,10 @@ import {useMyLabelersQuery} from '#/state/queries/preferences'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog' export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {AppBskyLabelerDefs} from '@atproto/api' import {AppBskyLabelerDefs} from '@atproto/api'
import {BottomSheetScrollViewMethods} from '@discord/bottom-sheet/src'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading' import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
import {useOnKeyboardDidShow} from '#/components/hooks/useOnKeyboard'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {SelectLabelerView} from './SelectLabelerView' import {SelectLabelerView} from './SelectLabelerView'
@@ -25,7 +24,6 @@ export function ReportDialog(props: ReportDialogProps) {
return ( return (
<Dialog.Outer control={props.control}> <Dialog.Outer control={props.control}>
<Dialog.Handle /> <Dialog.Handle />
<ReportDialogInner {...props} /> <ReportDialogInner {...props} />
</Dialog.Outer> </Dialog.Outer>
) )
@@ -40,10 +38,7 @@ function ReportDialogInner(props: ReportDialogProps) {
} = useMyLabelersQuery() } = useMyLabelersQuery()
const isLoading = useDelayedLoading(500, isLabelerLoading) const isLoading = useDelayedLoading(500, isLabelerLoading)
const ref = React.useRef<BottomSheetScrollViewMethods>(null) const ref = React.useRef<ScrollView>(null)
useOnKeyboardDidShow(() => {
ref.current?.scrollToEnd({animated: true})
})
return ( return (
<Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}> <Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}>
@@ -149,7 +149,6 @@ export function QrCodeDialog({
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={_(msg`Create a QR code for a starter pack`)}> label={_(msg`Create a QR code for a starter pack`)}>
<View style={[a.flex_1, a.align_center, a.gap_5xl]}> <View style={[a.flex_1, a.align_center, a.gap_5xl]}>
+8 -8
View File
@@ -6,14 +6,14 @@ import {AppBskyGraphDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {logger} from '#/logger'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {isNative, isWeb} from '#/platform/detection'
import {saveImageToMediaLibrary} from 'lib/media/manip' import * as Toast from '#/view/com/util/Toast'
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 {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {DialogControlProps} from '#/components/Dialog' import {DialogControlProps} from '#/components/Dialog'
@@ -32,6 +32,7 @@ interface Props {
export function ShareDialog(props: Props) { export function ShareDialog(props: Props) {
return ( return (
<Dialog.Outer control={props.control}> <Dialog.Outer control={props.control}>
<Dialog.Handle />
<ShareDialogInner {...props} /> <ShareDialogInner {...props} />
</Dialog.Outer> </Dialog.Outer>
) )
@@ -84,7 +85,6 @@ function ShareDialogInner({
return ( return (
<> <>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Share link dialog`)}> <Dialog.ScrollableInner label={_(msg`Share link dialog`)}>
{!imageLoaded || !link ? ( {!imageLoaded || !link ? (
<View style={[a.p_xl, a.align_center]}> <View style={[a.p_xl, a.align_center]}>
@@ -3,13 +3,13 @@ import type {ListRenderItemInfo} from 'react-native'
import {View} from 'react-native' import {View} from 'react-native'
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' 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 {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {isWeb} from 'platform/detection' import {isWeb} from '#/platform/detection'
import {useSession} from 'state/session' import {useSession} from '#/state/session'
import {ListMethods} from '#/view/com/util/List'
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State' import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, native, useTheme, web} from '#/alf' import {atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
@@ -45,7 +45,7 @@ export function WizardEditListDialog({
const {currentAccount} = useSession() const {currentAccount} = useSession()
const initialNumToRender = useInitialNumToRender() const initialNumToRender = useInitialNumToRender()
const listRef = useRef<BottomSheetFlatListMethods>(null) const listRef = useRef<ListMethods>(null)
const getData = () => { const getData = () => {
if (state.currentStep === 'Feeds') return state.feeds if (state.currentStep === 'Feeds') return state.feeds
@@ -76,10 +76,7 @@ export function WizardEditListDialog({
) )
return ( return (
<Dialog.Outer <Dialog.Outer control={control} testID="newChatDialog">
control={control}
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['95%']}}}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.InnerFlatList <Dialog.InnerFlatList
ref={listRef} ref={listRef}
@@ -89,6 +86,7 @@ export function WizardEditListDialog({
ListHeaderComponent={ ListHeaderComponent={
<View <View
style={[ style={[
native(a.pt_4xl),
a.flex_row, a.flex_row,
a.justify_between, a.justify_between,
a.border_b, a.border_b,
@@ -103,13 +101,7 @@ export function WizardEditListDialog({
height: 48, height: 48,
}, },
] ]
: [ : [a.pb_sm, a.align_end],
a.pb_sm,
a.align_end,
{
height: 68,
},
],
]}> ]}>
<View style={{width: 60}} /> <View style={{width: 60}} />
<Text style={[a.font_bold, a.text_xl]}> <Text style={[a.font_bold, a.text_xl]}>
@@ -143,8 +135,6 @@ export function WizardEditListDialog({
paddingHorizontal: 0, paddingHorizontal: 0,
marginTop: 0, marginTop: 0,
paddingTop: 0, paddingTop: 0,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
}), }),
]} ]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
-1
View File
@@ -85,7 +85,6 @@ export function TagMenu({
<Dialog.Outer control={control}> <Dialog.Outer control={control}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}> <Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
{isPreferencesLoading ? ( {isPreferencesLoading ? (
<View style={[a.w_full, a.align_center]}> <View style={[a.w_full, a.align_center]}>
@@ -31,7 +31,6 @@ export function BirthDateSettingsDialog({
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`My Birthday`)}> <Dialog.ScrollableInner label={_(msg`My Birthday`)}>
<View style={[a.gap_sm, a.pb_lg]}> <View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_2xl, a.font_bold]}> <Text style={[a.text_2xl, a.font_bold]}>
-1
View File
@@ -50,7 +50,6 @@ export function EmbedConsentDialog({
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={_(msg`External Media`)} label={_(msg`External Media`)}
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}> style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
-255
View File
@@ -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>
)
}
@@ -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>
)
}
+66 -9
View File
@@ -6,10 +6,12 @@ import React, {
useState, useState,
} from 'react' } from 'react'
import {TextInput, View} from 'react-native' 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 {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import { import {
@@ -19,7 +21,8 @@ import {
} from '#/state/queries/tenor' } from '#/state/queries/tenor'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 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 Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {useThrottledValue} from '#/components/hooks/useThrottledValue' 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 {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Button, ButtonIcon, ButtonText} from '../Button' import {Button, ButtonIcon, ButtonText} from '../Button'
import {ListFooter, ListMaybePlaceholder} from '../Lists' import {ListFooter, ListMaybePlaceholder} from '../Lists'
import {GifPreview} from './GifSelect.shared' import {PortalComponent} from '../Portal'
export function GifSelectDialog({ export function GifSelectDialog({
controlRef, controlRef,
onClose, onClose,
onSelectGif: onSelectGifProp, onSelectGif: onSelectGifProp,
Portal,
}: { }: {
controlRef: React.RefObject<{open: () => void}> controlRef: React.RefObject<{open: () => void}>
onClose: () => void onClose: () => void
onSelectGif: (gif: Gif) => void onSelectGif: (gif: Gif) => void
Portal?: PortalComponent
}) { }) {
const control = Dialog.useDialogControl() const control = Dialog.useDialogControl()
@@ -59,8 +64,13 @@ export function GifSelectDialog({
return ( return (
<Dialog.Outer <Dialog.Outer
control={control} 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 /> <Dialog.Handle />
<ErrorBoundary renderError={renderErrorBoundary}> <ErrorBoundary renderError={renderErrorBoundary}>
<GifList control={control} onSelectGif={onSelectGif} /> <GifList control={control} onSelectGif={onSelectGif} />
@@ -80,9 +90,10 @@ function GifList({
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const textInputRef = useRef<TextInput>(null) const textInputRef = useRef<TextInput>(null)
const listRef = useRef<BottomSheetFlatListMethods>(null) const listRef = useRef<ListMethods>(null)
const [undeferredSearch, setSearch] = useState('') const [undeferredSearch, setSearch] = useState('')
const search = useThrottledValue(undeferredSearch, 500) const search = useThrottledValue(undeferredSearch, 500)
const {height} = useWindowDimensions()
const isSearching = search.length > 0 const isSearching = search.length > 0
@@ -95,7 +106,7 @@ function GifList({
isFetchingNextPage, isFetchingNextPage,
hasNextPage, hasNextPage,
error, error,
isLoading, isPending,
isError, isError,
refetch, refetch,
} = isSearching ? searchQuery : trendingQuery } = isSearching ? searchQuery : trendingQuery
@@ -132,6 +143,7 @@ function GifList({
return ( return (
<View <View
style={[ style={[
native(a.pt_4xl),
a.relative, a.relative,
a.mb_lg, a.mb_lg,
a.flex_row, a.flex_row,
@@ -196,13 +208,14 @@ function GifList({
data={flattenedData} data={flattenedData}
renderItem={renderItem} renderItem={renderItem}
numColumns={gtMobile ? 3 : 2} numColumns={gtMobile ? 3 : 2}
columnWrapperStyle={a.gap_sm} columnWrapperStyle={[a.gap_sm]}
contentContainerStyle={[native([a.px_xl, {minHeight: height}])]}
ListHeaderComponent={ ListHeaderComponent={
<> <>
{listHeader} {listHeader}
{!hasData && ( {!hasData && (
<ListMaybePlaceholder <ListMaybePlaceholder
isLoading={isLoading} isLoading={isPending}
isError={isError} isError={isError}
onRetry={refetch} onRetry={refetch}
onGoBack={onGoBack} onGoBack={onGoBack}
@@ -273,3 +286,47 @@ function DialogError({details}: {details?: string}) {
</Dialog.ScrollableInner> </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>
)
}
+333 -287
View File
@@ -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 {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {createPortalGroup} from '#/components/Portal'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
const ONE_DAY = 24 * 60 * 60 * 1000 const ONE_DAY = 24 * 60 * 60 * 1000
const Portal = createPortalGroup()
export function MutedWordsDialog() { export function MutedWordsDialog() {
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext() const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
return ( return (
@@ -105,307 +108,349 @@ function MutedWordsInner() {
}, [_, field, targets, addMutedWord, setField, durations, excludeFollowing]) }, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
return ( return (
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}> <Portal.Provider>
<View> <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
<Text <View>
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>
<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,
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>
<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>
<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]}>
<Toggle.Item
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>Text & tags</Trans>
</Toggle.LabelText>
</View>
<PageText size="sm" />
</TargetToggle>
</Toggle.Item>
<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>
</Toggle.Group>
<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`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
style={[
a.italic,
{color: t.palette.white},
native({marginTop: 2}),
]}>
{error}
</Text>
</View>
)}
</View>
<Divider />
<View style={[a.pt_2xl]}>
<Text <Text
style={[ style={[
a.text_md, a.text_md,
a.font_bold, a.font_bold,
a.pb_md, a.pb_sm,
t.atoms.text_contrast_high, t.atoms.text_contrast_high,
]}> ]}>
<Trans>Your muted words</Trans> <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> </Text>
{isPreferencesLoading ? ( <View style={[a.pb_sm]}>
<Loader /> <Dialog.Input
) : preferencesError || !preferences ? ( autoCorrect={false}
<View autoCapitalize="none"
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}> autoComplete="off"
<Text style={[a.italic, t.atoms.text_contrast_high]}> label={_(msg`Enter a word or tag`)}
<Trans> placeholder={_(msg`Enter a word or tag`)}
We're sorry, but we weren't able to load your muted words at value={field}
this time. Please try again. onChangeText={value => {
</Trans> 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> </Text>
</View>
) : preferences.moderationPrefs.mutedWords.length ? ( <View
[...preferences.moderationPrefs.mutedWords] style={[
.reverse() gtMobile && [a.flex_row, a.align_center, a.justify_start],
.map((word, i) => ( a.gap_sm,
<MutedWordRow ]}>
key={word.value + i} <View
word={word} style={[
style={[i % 2 === 0 && t.atoms.bg_contrast_25]} a.flex_1,
/> a.flex_row,
)) a.justify_start,
) : ( a.align_center,
<View a.gap_sm,
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}> ]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}> <Toggle.Item
<Trans>You haven't muted any words or tags yet</Trans> 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>
<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>
<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> </Text>
<View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
<Toggle.Item
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>Text & tags</Trans>
</Toggle.LabelText>
</View>
<PageText size="sm" />
</TargetToggle>
</Toggle.Item>
<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>
</Toggle.Group>
<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`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>
)}
<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
style={[
a.italic,
{color: t.palette.white},
native({marginTop: 2}),
]}>
{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>
{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>
)}
</View>
{isNative && <View style={{height: 20}} />}
</View> </View>
{isNative && <View style={{height: 20}} />} <Dialog.Close />
</View> </Dialog.ScrollableInner>
<Dialog.Close /> <Portal.Outlet />
</Dialog.ScrollableInner> </Portal.Provider>
) )
} }
@@ -437,6 +482,7 @@ function MutedWordRow({
onConfirm={remove} onConfirm={remove}
confirmButtonCta={_(msg`Remove`)} confirmButtonCta={_(msg`Remove`)}
confirmButtonColor="negative" confirmButtonColor="negative"
Portal={Portal.Portal}
/> />
<View <View
@@ -37,6 +37,7 @@ import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export type PostInteractionSettingsFormProps = { export type PostInteractionSettingsFormProps = {
@@ -54,13 +55,15 @@ export type PostInteractionSettingsFormProps = {
export function PostInteractionSettingsControlledDialog({ export function PostInteractionSettingsControlledDialog({
control, control,
Portal,
...rest ...rest
}: PostInteractionSettingsFormProps & { }: PostInteractionSettingsFormProps & {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
Portal?: PortalComponent
}) { }) {
const {_} = useLingui() const {_} = useLingui()
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control} Portal={Portal}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)} label={_(msg`Edit post interaction settings`)}
@@ -231,7 +234,6 @@ export function PostInteractionSettingsForm({
}: PostInteractionSettingsFormProps) { }: PostInteractionSettingsFormProps) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate') const {data: lists} = useMyListsQuery('curate')
const [quotesEnabled, setQuotesEnabled] = React.useState( const [quotesEnabled, setQuotesEnabled] = React.useState(
!( !(
@@ -437,7 +439,6 @@ export function PostInteractionSettingsForm({
<Button <Button
label={_(msg`Save`)} label={_(msg`Save`)}
onPress={onSave} onPress={onSave}
onAccessibilityEscape={control.close}
color="primary" color="primary"
size="large" size="large"
variant="solid" variant="solid"
-1
View File
@@ -43,7 +43,6 @@ export function SwitchAccountDialog({
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Switch Account`)}> <Dialog.ScrollableInner label={_(msg`Switch Account`)}>
<View style={[a.gap_lg]}> <View style={[a.gap_lg]}>
<Text style={[a.text_2xl, a.font_bold]}> <Text style={[a.text_2xl, a.font_bold]}>
@@ -44,7 +44,6 @@ export function NeueTypography() {
return ( return (
<Dialog.Outer control={control} onClose={onClose}> <Dialog.Outer control={control} onClose={onClose}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Introducing new font settings`)}> <Dialog.ScrollableInner label={_(msg`Introducing new font settings`)}>
<View style={[a.gap_xl]}> <View style={[a.gap_xl]}>
<View style={[a.gap_md]}> <View style={[a.gap_md]}>
+3 -3
View File
@@ -136,7 +136,7 @@ let ConvoMenu = ({
<Menu.Outer> <Menu.Outer>
<Menu.Item <Menu.Item
label={_(msg`Leave conversation`)} label={_(msg`Leave conversation`)}
onPress={leaveConvoControl.open}> onPress={() => leaveConvoControl.open()}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Leave conversation</Trans> <Trans>Leave conversation</Trans>
</Menu.ItemText> </Menu.ItemText>
@@ -195,7 +195,7 @@ let ConvoMenu = ({
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
label={_(msg`Report conversation`)} label={_(msg`Report conversation`)}
onPress={reportControl.open}> onPress={() => reportControl.open()}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Report conversation</Trans> <Trans>Report conversation</Trans>
</Menu.ItemText> </Menu.ItemText>
@@ -206,7 +206,7 @@ let ConvoMenu = ({
<Menu.Group> <Menu.Group>
<Menu.Item <Menu.Item
label={_(msg`Leave conversation`)} label={_(msg`Leave conversation`)}
onPress={leaveConvoControl.open}> onPress={() => leaveConvoControl.open()}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Leave conversation</Trans> <Trans>Leave conversation</Trans>
</Menu.ItemText> </Menu.ItemText>
+5 -5
View File
@@ -7,11 +7,11 @@ import {useLingui} from '@lingui/react'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers' import {getTranslatorLink} from '#/locale/helpers'
import {isWeb} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {useOpenLink} from '#/state/preferences/in-app-browser' import {useOpenLink} from '#/state/preferences/in-app-browser'
import {isWeb} from 'platform/detection' import {useSession} from '#/state/session'
import {useConvoActive} from 'state/messages/convo'
import {useSession} from 'state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {ReportDialog} from '#/components/dms/ReportDialog' import {ReportDialog} from '#/components/dms/ReportDialog'
@@ -120,7 +120,7 @@ export let MessageMenu = ({
<Menu.Item <Menu.Item
testID="messageDropdownDeleteBtn" testID="messageDropdownDeleteBtn"
label={_(msg`Delete message for me`)} label={_(msg`Delete message for me`)}
onPress={deleteControl.open}> onPress={() => deleteControl.open()}>
<Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText> <Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" /> <Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item> </Menu.Item>
@@ -128,7 +128,7 @@ export let MessageMenu = ({
<Menu.Item <Menu.Item
testID="messageDropdownReportBtn" testID="messageDropdownReportBtn"
label={_(msg`Report message`)} label={_(msg`Report message`)}
onPress={reportControl.open}> onPress={() => reportControl.open()}>
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText> <Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" /> <Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item> </Menu.Item>
+1 -6
View File
@@ -10,13 +10,11 @@ import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {ReportOption} from '#/lib/moderation/useReportOptions' import {ReportOption} from '#/lib/moderation/useReportOptions'
import {isAndroid} from '#/platform/detection'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {KeyboardControllerPadding} from '#/components/KeyboardControllerPadding'
import {Button, ButtonIcon, ButtonText} from '../Button' import {Button, ButtonIcon, ButtonText} from '../Button'
import {Divider} from '../Divider' import {Divider} from '../Divider'
import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '../icons/Chevron' import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '../icons/Chevron'
@@ -41,14 +39,11 @@ let ReportDialog = ({
}): React.ReactNode => { }): React.ReactNode => {
const {_} = useLingui() const {_} = useLingui()
return ( return (
<Dialog.Outer <Dialog.Outer control={control}>
control={control}
nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Report this message`)}> <Dialog.ScrollableInner label={_(msg`Report this message`)}>
<DialogInner params={params} /> <DialogInner params={params} />
<Dialog.Close /> <Dialog.Close />
<KeyboardControllerPadding />
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
</Dialog.Outer> </Dialog.Outer>
) )
+3 -5
View File
@@ -2,9 +2,9 @@ import React, {useCallback} from 'react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 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 {FAB} from '#/view/com/util/fab/FAB'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
@@ -55,10 +55,8 @@ export function NewChat({
accessibilityHint="" accessibilityHint=""
/> />
<Dialog.Outer <Dialog.Outer control={control} testID="newChatDialog">
control={control} <Dialog.Handle />
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<SearchablePeopleList <SearchablePeopleList
title={_(msg`Start a new chat`)} title={_(msg`Start a new chat`)}
onSelectChat={onCreateChat} onSelectChat={onCreateChat}
@@ -5,10 +5,8 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import type {TextInput as TextInputType} from 'react-native' import {TextInput, View} from 'react-native'
import {View} from 'react-native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -16,18 +14,17 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useListConvosQuery} from '#/state/queries/messages/list-converations' import {useListConvosQuery} from '#/state/queries/messages/list-converations'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session' 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 {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf' 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 * as Dialog from '#/components/Dialog'
import {TextInput} from '#/components/dms/dialogs/TextInput'
import {canBeMessaged} from '#/components/dms/util' import {canBeMessaged} from '#/components/dms/util'
import {useInteractionState} from '#/components/hooks/useInteractionState' 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 {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -66,9 +63,9 @@ export function SearchablePeopleList({
const {_} = useLingui() const {_} = useLingui()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const control = Dialog.useDialogContext() const control = Dialog.useDialogContext()
const listRef = useRef<BottomSheetFlatListMethods>(null) const listRef = useRef<ListMethods>(null)
const {currentAccount} = useSession() const {currentAccount} = useSession()
const inputRef = useRef<TextInputType>(null) const inputRef = useRef<TextInput>(null)
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
@@ -101,15 +98,15 @@ export function SearchablePeopleList({
}) })
} }
_items = _items.sort(a => { _items = _items.sort(item => {
// @ts-ignore // @ts-ignore
return a.enabled ? -1 : 1 return item.enabled ? -1 : 1
}) })
} }
} else { } else {
const placeholders: Item[] = Array(10) const placeholders: Item[] = Array(10)
.fill(0) .fill(0)
.map((_, i) => ({ .map((__, i) => ({
type: 'placeholder', type: 'placeholder',
key: i + '', key: i + '',
})) }))
@@ -155,9 +152,9 @@ export function SearchablePeopleList({
} }
// only sort follows // only sort follows
followsItems = followsItems.sort(a => { followsItems = followsItems.sort(item => {
// @ts-ignore // @ts-ignore
return a.enabled ? -1 : 1 return item.enabled ? -1 : 1
}) })
// then append // then append
@@ -177,9 +174,9 @@ export function SearchablePeopleList({
} }
} }
_items = _items.sort(a => { _items = _items.sort(item => {
// @ts-ignore // @ts-ignore
return a.enabled ? -1 : 1 return item.enabled ? -1 : 1
}) })
} else { } else {
_items.push(...placeholders) _items.push(...placeholders)
@@ -242,57 +239,46 @@ export function SearchablePeopleList({
<View <View
style={[ style={[
a.relative, a.relative,
a.pt_md, web(a.pt_lg),
native(a.pt_4xl),
a.pb_xs, a.pb_xs,
a.px_lg, a.px_lg,
a.border_b, a.border_b,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
t.atoms.bg, t.atoms.bg,
native([a.pt_lg]),
]}> ]}>
<View <View style={[a.relative, native(a.align_center), a.justify_center]}>
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>
<Text <Text
style={[ style={[
a.z_10, a.z_10,
a.text_lg, a.text_lg,
a.font_bold, a.font_heavy,
a.leading_tight, a.leading_tight,
t.atoms.text_contrast_high, t.atoms.text_contrast_high,
]}> ]}>
{title} {title}
</Text> </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>
<View style={[native([a.pt_sm]), web([a.pt_xs])]}> <View style={[, web([a.pt_xs])]}>
<SearchInput <SearchInput
inputRef={inputRef} inputRef={inputRef}
value={searchText} value={searchText}
@@ -309,7 +295,6 @@ export function SearchablePeopleList({
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
t.atoms.bg, t.atoms.bg,
t.atoms.text_contrast_high, t.atoms.text_contrast_high,
t.palette.contrast_500,
_, _,
title, title,
searchText, searchText,
@@ -326,14 +311,7 @@ export function SearchablePeopleList({
keyExtractor={(item: Item) => item.key} keyExtractor={(item: Item) => item.key}
style={[ style={[
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
native({ native({height: '100%'}),
height: '100%',
paddingHorizontal: 0,
marginTop: 0,
paddingTop: 0,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
}),
]} ]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
@@ -396,7 +374,8 @@ function ProfileCard({
<View style={[a.flex_1, a.gap_2xs]}> <View style={[a.flex_1, a.gap_2xs]}>
<Text <Text
style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]} style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]}
numberOfLines={1}> numberOfLines={1}
emoji>
{displayName} {displayName}
</Text> </Text>
<Text <Text
@@ -474,7 +453,7 @@ function SearchInput({
value: string value: string
onChangeText: (text: string) => void onChangeText: (text: string) => void
onEscape: () => void onEscape: () => void
inputRef: React.RefObject<TextInputType> inputRef: React.RefObject<TextInput>
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@@ -2,9 +2,9 @@ import React, {useCallback} from 'react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 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 Toast from '#/view/com/util/Toast'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from './SearchablePeopleList' import {SearchablePeopleList} from './SearchablePeopleList'
@@ -17,10 +17,8 @@ export function SendViaChatDialog({
onSelectChat: (chatId: string) => void onSelectChat: (chatId: string) => void
}) { }) {
return ( return (
<Dialog.Outer <Dialog.Outer control={control} testID="sendViaChatChatDialog">
control={control} <Dialog.Handle />
testID="sendViaChatChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<SendViaChatDialogInner control={control} onSelectChat={onSelectChat} /> <SendViaChatDialogInner control={control} onSelectChat={onSelectChat} />
</Dialog.Outer> </Dialog.Outer>
) )
+1 -1
View File
@@ -2,8 +2,8 @@ import React from 'react'
import {Pressable, View, ViewStyle} from 'react-native' import {Pressable, View, ViewStyle} from 'react-native'
import Animated, {LinearTransition} from 'react-native-reanimated' import Animated, {LinearTransition} from 'react-native-reanimated'
import {HITSLOP_10} from '#/lib/constants'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {HITSLOP_10} from 'lib/constants'
import { import {
atoms as a, atoms as a,
flatten, flatten,
+36 -35
View File
@@ -32,7 +32,6 @@ export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
return ( return (
<Dialog.Outer control={props.control}> <Dialog.Outer control={props.control}>
<Dialog.Handle /> <Dialog.Handle />
<LabelsOnMeDialogInner {...props} /> <LabelsOnMeDialogInner {...props} />
</Dialog.Outer> </Dialog.Outer>
) )
@@ -158,23 +157,25 @@ function Label({
<Divider /> <Divider />
<View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}> <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>This label was applied by you.</Trans>
) : ( </Text>
<Trans> ) : (
Source:{' '} <View style={{flexDirection: 'row'}}>
<InlineLinkText <Text style={[t.atoms.text_contrast_medium]}>
label={sourceName} <Trans>Source: </Trans>{' '}
to={makeProfileLink( </Text>
labeler ? labeler.creator : {did: label.src, handle: ''}, <InlineLinkText
)} label={sourceName}
onPress={() => control.close()}> to={makeProfileLink(
{sourceName} labeler ? labeler.creator : {did: label.src, handle: ''},
</InlineLinkText> )}
</Trans> onPress={() => control.close()}>
)} {sourceName}
</Text> </InlineLinkText>
</View>
)}
</View> </View>
</View> </View>
) )
@@ -236,24 +237,24 @@ function AppealForm({
return ( return (
<> <>
<Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}> <View style={{flexWrap: 'wrap', flexDirection: 'row'}}>
<Trans>Appeal "{strings.name}" label</Trans> <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
</Text> <Trans>Appeal "{strings.name}" label</Trans>
<Text style={[a.text_md, a.leading_snug]}> </Text>
<Trans> <Text style={[a.text_md, a.leading_snug]}>
This appeal will be sent to{' '} <Trans>This appeal will be sent to</Trans>{' '}
<InlineLinkText </Text>
label={sourceName} <InlineLinkText
to={makeProfileLink( label={sourceName}
labeler ? labeler.creator : {did: label.src, handle: ''}, to={makeProfileLink(
)} labeler ? labeler.creator : {did: label.src, handle: ''},
onPress={() => control.close()} )}
style={[a.text_md, a.leading_snug]}> onPress={() => control.close()}
{sourceName} style={[a.text_md, a.leading_snug]}>
</InlineLinkText> {sourceName}
. </InlineLinkText>
</Trans> <Text style={[a.text_md, a.leading_snug]}>.</Text>
</Text> </View>
<View style={[a.my_md]}> <View style={[a.my_md]}>
<Dialog.Input <Dialog.Input
label={_(msg`Text input field`)} label={_(msg`Text input field`)}
@@ -141,23 +141,24 @@ function ModerationDetailsDialogInner({
{modcause?.type === 'label' && ( {modcause?.type === 'label' && (
<View style={[a.pt_lg]}> <View style={[a.pt_lg]}>
<Divider /> <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 the author.</Trans>
) : ( </Text>
<Trans> ) : (
This label was applied by{' '} <>
<InlineLinkText <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
label={desc.source || _(msg`an unknown labeler`)} <Trans>This label was applied by </Trans>
to={makeProfileLink({did: modcause.label.src, handle: ''})} </Text>
onPress={() => control.close()} <InlineLinkText
style={a.text_md}> label={desc.source || _(msg`an unknown labeler`)}
{desc.source || _(msg`an unknown labeler`)} to={makeProfileLink({did: modcause.label.src, handle: ''})}
</InlineLinkText> onPress={() => control.close()}
. style={a.text_md}>
</Trans> {desc.source || _(msg`an unknown labeler`)}
)} </InlineLinkText>
</Text> </>
)}
</View> </View>
)} )}
+1 -1
View File
@@ -7,8 +7,8 @@ import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable' import {AbortError} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors' import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types' import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from './util'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
export async function uploadVideo({ export async function uploadVideo({
video, video,
+1 -1
View File
@@ -7,8 +7,8 @@ import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable' import {AbortError} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors' import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types' import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from './util'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
export async function uploadVideo({ export async function uploadVideo({
video, video,
+24 -13
View File
@@ -32,6 +32,7 @@ import {
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {IconCircle} from '#/components/IconCircle' import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo'
@@ -89,15 +90,18 @@ export function StepProfile() {
requestNotificationsPermission('StartOnboarding') requestNotificationsPermission('StartOnboarding')
}, [gate, requestNotificationsPermission]) }, [gate, requestNotificationsPermission])
const sheetWrapper = useSheetWrapper()
const openPicker = React.useCallback( const openPicker = React.useCallback(
async (opts?: ImagePickerOptions) => { async (opts?: ImagePickerOptions) => {
const response = await launchImageLibraryAsync({ const response = await sheetWrapper(
exif: false, launchImageLibraryAsync({
mediaTypes: MediaTypeOptions.Images, exif: false,
quality: 1, mediaTypes: MediaTypeOptions.Images,
...opts, quality: 1,
legacy: true, ...opts,
}) legacy: true,
}),
)
return (response.assets ?? []) return (response.assets ?? [])
.slice(0, 1) .slice(0, 1)
@@ -121,7 +125,7 @@ export function StepProfile() {
size: getDataUriSize(image.uri), size: getDataUriSize(image.uri),
})) }))
}, },
[_, setError], [_, setError, sheetWrapper],
) )
const onContinue = React.useCallback(async () => { const onContinue = React.useCallback(async () => {
@@ -168,9 +172,11 @@ export function StepProfile() {
setError('') setError('')
const items = await openPicker({ const items = await sheetWrapper(
aspect: [1, 1], openPicker({
}) aspect: [1, 1],
}),
)
let image = items[0] let image = items[0]
if (!image) return if (!image) return
@@ -196,7 +202,13 @@ export function StepProfile() {
image, image,
useCreatedAvatar: false, useCreatedAvatar: false,
})) }))
}, [requestPhotoAccessIfNeeded, setAvatar, openPicker, setError]) }, [
requestPhotoAccessIfNeeded,
setAvatar,
openPicker,
setError,
sheetWrapper,
])
const onSecondaryPress = React.useCallback(() => { const onSecondaryPress = React.useCallback(() => {
if (avatar.useCreatedAvatar) { if (avatar.useCreatedAvatar) {
@@ -286,7 +298,6 @@ export function StepProfile() {
</View> </View>
<Dialog.Outer control={creatorControl}> <Dialog.Outer control={creatorControl}>
<Dialog.Handle />
<Dialog.Inner <Dialog.Inner
label="Avatar creator" label="Avatar creator"
style={[ style={[
+22 -22
View File
@@ -15,35 +15,35 @@ import {useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack' import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query' 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 {cleanError} from '#/lib/strings/errors'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
import {logger} from '#/logger' 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 {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 { import {
ProgressGuideAction, ProgressGuideAction,
useProgressGuideControls, useProgressGuideControls,
} from '#/state/shell/progress-guide' } from '#/state/shell/progress-guide'
import {batchedUpdates} from 'lib/batchedUpdates' import {useSetActiveStarterPack} from '#/state/shell/starter-pack'
import {HITSLOP_20} from 'lib/constants' import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
import {isBlockedOrBlocking, isMuted} from 'lib/moderation/blocked-and-muted' import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader'
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 * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {CenteredView} from '#/view/com/util/Views'
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
import {CenteredView} from 'view/com/util/Views'
import {bulkWriteFollows} from '#/screens/Onboarding/util' import {bulkWriteFollows} from '#/screens/Onboarding/util'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -591,7 +591,7 @@ function OverflowMenu({
<Menu.Item <Menu.Item
label={_(msg`Report starter pack`)} label={_(msg`Report starter pack`)}
onPress={reportDialogControl.open}> onPress={() => reportDialogControl.open()}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Report starter pack</Trans> <Trans>Report starter pack</Trans>
</Menu.ItemText> </Menu.ItemText>
+47 -40
View File
@@ -1,8 +1,9 @@
import React from 'react' import React from 'react'
import {SharedValue, useSharedValue} from 'react-native-reanimated'
import {isWeb} from '#/platform/detection'
import {DialogControlRefProps} from '#/components/Dialog' import {DialogControlRefProps} from '#/components/Dialog'
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context' import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
import {BottomSheet} from '../../../modules/bottom-sheet'
interface IDialogContext { interface IDialogContext {
/** /**
@@ -16,25 +17,24 @@ interface IDialogContext {
* `useId`. * `useId`.
*/ */
openDialogs: React.MutableRefObject<Set<string>> openDialogs: React.MutableRefObject<Set<string>>
}
interface IDialogControlContext {
closeAllDialogs(): boolean
setDialogIsOpen(id: string, isOpen: boolean): void
/** /**
* The counterpart to `accessibilityViewIsModal` for Android. This property * The number of dialogs that are fully expanded. This is used to determine the backgground color of the status bar
* applies to the parent of all non-modal views, and prevents TalkBack from * on iOS.
* navigating within content beneath an open dialog.
*
* @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android
*/ */
importantForAccessibility: SharedValue<'auto' | 'no-hide-descendants'> fullyExpandedCount: number
setFullyExpandedCount: React.Dispatch<React.SetStateAction<number>>
} }
const DialogContext = React.createContext<IDialogContext>({} as IDialogContext) const DialogContext = React.createContext<IDialogContext>({} as IDialogContext)
const DialogControlContext = React.createContext<{ const DialogControlContext = React.createContext<IDialogControlContext>(
closeAllDialogs(): boolean {} as IDialogControlContext,
setDialogIsOpen(id: string, isOpen: boolean): void )
}>({
closeAllDialogs: () => false,
setDialogIsOpen: () => {},
})
export function useDialogStateContext() { export function useDialogStateContext() {
return React.useContext(DialogContext) return React.useContext(DialogContext)
@@ -45,48 +45,55 @@ export function useDialogStateControlContext() {
} }
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
const [fullyExpandedCount, setFullyExpandedCount] = React.useState(0)
const activeDialogs = React.useRef< const activeDialogs = React.useRef<
Map<string, React.MutableRefObject<DialogControlRefProps>> Map<string, React.MutableRefObject<DialogControlRefProps>>
>(new Map()) >(new Map())
const openDialogs = React.useRef<Set<string>>(new Set()) const openDialogs = React.useRef<Set<string>>(new Set())
const importantForAccessibility = useSharedValue<
'auto' | 'no-hide-descendants'
>('auto')
const closeAllDialogs = React.useCallback(() => { const closeAllDialogs = React.useCallback(() => {
openDialogs.current.forEach(id => { if (isWeb) {
const dialog = activeDialogs.current.get(id) openDialogs.current.forEach(id => {
if (dialog) dialog.current.close() const dialog = activeDialogs.current.get(id)
}) if (dialog) dialog.current.close()
return openDialogs.current.size > 0 })
return openDialogs.current.size > 0
} else {
BottomSheet.dismissAll()
return false
}
}, []) }, [])
const setDialogIsOpen = React.useCallback( const setDialogIsOpen = React.useCallback((id: string, isOpen: boolean) => {
(id: string, isOpen: boolean) => { if (isOpen) {
if (isOpen) { openDialogs.current.add(id)
openDialogs.current.add(id) } else {
importantForAccessibility.value = 'no-hide-descendants' openDialogs.current.delete(id)
} else { }
openDialogs.current.delete(id) }, [])
if (openDialogs.current.size < 1) {
importantForAccessibility.value = 'auto'
}
}
},
[importantForAccessibility],
)
const context = React.useMemo<IDialogContext>( const context = React.useMemo<IDialogContext>(
() => ({ () => ({
activeDialogs, activeDialogs,
openDialogs, openDialogs,
importantForAccessibility,
}), }),
[importantForAccessibility, activeDialogs, openDialogs], [activeDialogs, openDialogs],
) )
const controls = React.useMemo( const controls = React.useMemo(
() => ({closeAllDialogs, setDialogIsOpen}), () => ({
[closeAllDialogs, setDialogIsOpen], closeAllDialogs,
setDialogIsOpen,
fullyExpandedCount,
setFullyExpandedCount,
}),
[
closeAllDialogs,
setDialogIsOpen,
fullyExpandedCount,
setFullyExpandedCount,
],
) )
return ( return (
+6 -6
View File
@@ -2,14 +2,14 @@ import React from 'react'
import {Linking} from 'react-native' import {Linking} from 'react-native'
import * as WebBrowser from 'expo-web-browser' import * as WebBrowser from 'expo-web-browser'
import {isNative} from '#/platform/detection' import {usePalette} from '#/lib/hooks/usePalette'
import * as persisted from '#/state/persisted'
import {usePalette} from 'lib/hooks/usePalette'
import { import {
createBskyAppAbsoluteUrl, createBskyAppAbsoluteUrl,
isBskyRSSUrl, isBskyRSSUrl,
isRelativeUrl, 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' import {useModalControls} from '../modals'
type StateContext = persisted.Schema['useInAppBrowser'] type StateContext = persisted.Schema['useInAppBrowser']
@@ -62,7 +62,7 @@ export function useOpenLink() {
const pal = usePalette('default') const pal = usePalette('default')
const openLink = React.useCallback( const openLink = React.useCallback(
(url: string, override?: boolean) => { async (url: string, override?: boolean) => {
if (isBskyRSSUrl(url) && isRelativeUrl(url)) { if (isBskyRSSUrl(url) && isRelativeUrl(url)) {
url = createBskyAppAbsoluteUrl(url) url = createBskyAppAbsoluteUrl(url)
} }
@@ -75,7 +75,7 @@ export function useOpenLink() {
}) })
return return
} else if (override ?? enabled) { } else if (override ?? enabled) {
WebBrowser.openBrowserAsync(url, { await WebBrowser.openBrowserAsync(url, {
presentationStyle: presentationStyle:
WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN, WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN,
toolbarColor: pal.colors.backgroundLight, toolbarColor: pal.colors.backgroundLight,
+1 -5
View File
@@ -66,12 +66,8 @@ export function ServerInputDialog({
]) ])
return ( return (
<Dialog.Outer <Dialog.Outer control={control} onClose={onClose}>
control={control}
nativeOptions={{sheet: {snapPoints: ['100%']}}}
onClose={onClose}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner <Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description" accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title"> accessibilityLabelledBy="dialog-title">
+296 -276
View File
@@ -114,11 +114,14 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {createPortalGroup} from '#/components/Portal'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {Text as NewText} from '#/components/Typography' import {Text as NewText} from '#/components/Typography'
import {composerReducer, createComposerState} from './state/composer' import {composerReducer, createComposerState} from './state/composer'
import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
const Portal = createPortalGroup()
const MAX_IMAGES = 4 const MAX_IMAGES = 4
type CancelRef = { type CancelRef = {
@@ -613,296 +616,313 @@ export const ComposePost = ({
const keyboardVerticalOffset = useKeyboardVerticalOffset() const keyboardVerticalOffset = useKeyboardVerticalOffset()
return ( return (
<KeyboardAvoidingView <Portal.Provider>
testID="composePostView" <KeyboardAvoidingView
behavior={isIOS ? 'padding' : 'height'} testID="composePostView"
keyboardVerticalOffset={keyboardVerticalOffset} behavior={isIOS ? 'padding' : 'height'}
style={a.flex_1}> keyboardVerticalOffset={keyboardVerticalOffset}
<View style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal> style={a.flex_1}>
<Animated.View <View
style={topBarAnimatedStyle} style={[a.flex_1, viewStyles]}
layout={native(LinearTransition)}> aria-modal
<View style={styles.topbarInner}> accessibilityViewIsModal>
<Button <Animated.View
label={_(msg`Cancel`)} style={topBarAnimatedStyle}
variant="ghost" layout={native(LinearTransition)}>
color="primary" <View style={styles.topbarInner}>
shape="default" <Button
size="small" label={_(msg`Cancel`)}
style={[ variant="ghost"
a.rounded_full, color="primary"
a.py_sm, shape="default"
{paddingLeft: 7, paddingRight: 7}, size="small"
]} style={[
onPress={onPressCancel} a.rounded_full,
accessibilityHint={_( a.py_sm,
msg`Closes post composer and discards post draft`, {paddingLeft: 7, paddingRight: 7},
)}> ]}
<ButtonText style={[a.text_md]}> onPress={onPressCancel}
<Trans>Cancel</Trans> accessibilityHint={_(
</ButtonText> msg`Closes post composer and discards post draft`,
</Button> )}>
<View style={a.flex_1} /> <ButtonText style={[a.text_md]}>
{isProcessing ? ( <Trans>Cancel</Trans>
<> </ButtonText>
<Text style={pal.textLight}>{processingState}</Text> </Button>
<View style={styles.postBtn}> <View style={a.flex_1} />
<ActivityIndicator /> {isProcessing ? (
</View> <>
</> <Text style={pal.textLight}>{processingState}</Text>
) : ( <View style={styles.postBtn}>
<View style={[styles.postBtnWrapper]}> <ActivityIndicator />
<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 style={[styles.postBtnWrapper]}>
<LabelsBtn
labels={labels}
onChange={setLabels}
hasMedia={hasMedia}
/>
{canPost ? (
<Button
testID="composerPublishBtn"
label={
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
}
variant="solid"
color="primary"
shape="default"
size="small"
style={[a.rounded_full, a.py_sm]}
onPress={() => onPressPublish()}
disabled={
videoState.status !== 'idle' && publishOnUpload
}>
<ButtonText style={[a.text_md]}>
{replyTo ? (
<Trans context="action">Reply</Trans>
) : (
<Trans context="action">Post</Trans>
)}
</ButtonText>
</Button>
) : (
<View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>
<Trans context="action">Post</Trans>
</Text>
</View>
)}
</View>
)}
</View>
{isAltTextRequiredAndMissing && (
<View style={[styles.reminderLine, pal.viewLight]}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
/>
</View>
<Text style={[pal.text, a.flex_1]}>
<Trans>One or more images is missing alt text.</Trans>
</Text>
</View> </View>
)} )}
</View> <ErrorBanner
error={error}
{isAltTextRequiredAndMissing && ( videoState={videoState}
<View style={[styles.reminderLine, pal.viewLight]}> clearError={() => setError('')}
<View style={styles.errorIcon}> clearVideo={clearVideo}
<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}
/>
</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 </Animated.View>
ref={textInput} <Animated.ScrollView
richtext={richtext} layout={native(LinearTransition)}
placeholder={selectTextInputPlaceholder} onScroll={scrollHandler}
autoFocus style={styles.scrollView}
setRichText={setRichText} keyboardShouldPersistTaps="always"
onPhotoPasted={onPhotoPasted} onContentSizeChange={onScrollViewContentSizeChange}
onPressPublish={() => onPressPublish()} onLayout={onScrollViewLayout}>
onNewLink={onNewLink} {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
onError={setError}
accessible={true}
accessibilityLabel={_(msg`Write post`)}
accessibilityHint={_(
msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
)}
/>
</View>
<Gallery images={images} dispatch={dispatch} /> <View
{images.length === 0 && extLink && ( style={[
<View style={a.relative}> styles.textInputLayout,
<ExternalEmbed isNative && styles.textInputLayoutMobile,
link={extLink} ]}>
gif={extGif} <UserAvatar
onRemove={() => { avatar={currentProfile?.avatar}
if (extGif) { size={50}
dispatch({type: 'embed_remove_gif'}) type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
} else {
dispatch({type: 'embed_remove_link'})
}
setExtLink(undefined)
setExtGif(undefined)
}}
/> />
<GifAltText <TextInput
link={extLink} ref={textInput}
gif={extGif} richtext={richtext}
onSubmit={handleChangeGifAltText} 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> </View>
)}
<LayoutAnimationConfig skipExiting> <Gallery
{hasVideo && ( images={images}
<Animated.View dispatch={dispatch}
style={[a.w_full, a.mt_lg]} Portal={Portal.Portal}
entering={native(ZoomIn)} />
exiting={native(ZoomOut)}> {images.length === 0 && extLink && (
{videoState.asset && <View style={a.relative}>
(videoState.status === 'compressing' ? ( <ExternalEmbed
<VideoTranscodeProgress link={extLink}
asset={videoState.asset} gif={extGif}
progress={videoState.progress} onRemove={() => {
clear={clearVideo} if (extGif) {
/> dispatch({type: 'embed_remove_gif'})
) : videoState.video ? ( } else {
<VideoPreview dispatch({type: 'embed_remove_link'})
asset={videoState.asset} }
video={videoState.video} setExtLink(undefined)
setDimensions={updateVideoDimensions} setExtGif(undefined)
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,
},
})
}} }}
/> />
</Animated.View> <GifAltText
)} link={extLink}
</LayoutAnimationConfig> gif={extGif}
<View style={!hasVideo ? [a.mt_md] : []}> onSubmit={handleChangeGifAltText}
{quote ? ( Portal={Portal.Portal}
<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> </View>
) : null} )}
</View> <LayoutAnimationConfig skipExiting>
</Animated.ScrollView> {hasVideo && (
<SuggestedLanguage text={richtext.text} /> <Animated.View
style={[a.w_full, a.mt_lg]}
{replyTo ? null : ( entering={native(ZoomIn)}
<ThreadgateBtn exiting={native(ZoomOut)}>
postgate={postgate} {videoState.asset &&
onChangePostgate={setPostgate} (videoState.status === 'compressing' ? (
threadgateAllowUISettings={threadgateAllowUISettings} <VideoTranscodeProgress
onChangeThreadgateAllowUISettings={ asset={videoState.asset}
onChangeThreadgateAllowUISettings progress={videoState.progress}
} clear={clearVideo}
style={bottomBarAnimatedStyle} />
/> ) : videoState.video ? (
)} <VideoPreview
<View asset={videoState.asset}
style={[ video={videoState.video}
t.atoms.bg, setDimensions={updateVideoDimensions}
t.atoms.border_contrast_medium, clear={clearVideo}
styles.bottomBar, />
]}> ) : null)}
{videoState.status !== 'idle' && videoState.status !== 'done' ? ( <SubtitleDialogBtn
<VideoUploadToolbar state={videoState} /> defaultAltText={videoState.altText}
) : ( saveAltText={altText =>
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> dispatch({
<SelectPhotoBtn type: 'embed_update_video',
size={images.length} videoAction: {
disabled={!canSelectImages} type: 'update_alt_text',
onAdd={onImageAdd} altText,
/> signal: videoState.abortController.signal,
<SelectVideoBtn },
onSelectVideo={selectVideo} })
disabled={!canSelectImages || images?.length > 0} }
setError={setError} captions={videoState.captions}
/> setCaptions={updater => {
<OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} /> dispatch({
<SelectGifBtn type: 'embed_update_video',
onClose={focusTextInput} videoAction: {
onSelectGif={onSelectGif} type: 'update_captions',
disabled={hasMedia} updater,
/> signal: videoState.abortController.signal,
{!isMobile ? ( },
<Button })
onPress={onEmojiButtonPress} }}
style={a.p_sm} Portal={Portal.Portal}
label={_(msg`Open emoji picker`)} />
accessibilityHint={_(msg`Open emoji picker`)} </Animated.View>
variant="ghost" )}
shape="round" </LayoutAnimationConfig>
color="primary"> <View style={!hasVideo ? [a.mt_md] : []}>
<EmojiSmile size="lg" /> {quote ? (
</Button> <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} ) : 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} /> <View
<SelectLangBtn /> style={[
<CharProgress count={graphemeLength} /> 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>
</View> <Prompt.Basic
<Prompt.Basic control={discardPromptControl}
control={discardPromptControl} title={_(msg`Discard draft?`)}
title={_(msg`Discard draft?`)} description={_(msg`Are you sure you'd like to discard this draft?`)}
description={_(msg`Are you sure you'd like to discard this draft?`)} onConfirm={onClose}
onConfirm={onClose} confirmButtonCta={_(msg`Discard`)}
confirmButtonCta={_(msg`Discard`)} confirmButtonColor="negative"
confirmButtonColor="negative" Portal={Portal.Portal}
/> />
</KeyboardAvoidingView> </KeyboardAvoidingView>
<Portal.Outlet />
</Portal.Provider>
) )
} }
+6 -3
View File
@@ -20,6 +20,7 @@ import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {GifEmbed} from '../util/post-embeds/GifEmbed' import {GifEmbed} from '../util/post-embeds/GifEmbed'
import {AltTextReminder} from './photos/Gallery' import {AltTextReminder} from './photos/Gallery'
@@ -28,10 +29,12 @@ export function GifAltText({
link: linkProp, link: linkProp,
gif, gif,
onSubmit, onSubmit,
Portal,
}: { }: {
link: ExternalEmbedDraft link: ExternalEmbedDraft
gif?: Gif gif?: Gif
onSubmit: (alt: string) => void onSubmit: (alt: string) => void
Portal: PortalComponent
}) { }) {
const control = Dialog.useDialogControl() const control = Dialog.useDialogControl()
const {_} = useLingui() const {_} = useLingui()
@@ -96,9 +99,7 @@ export function GifAltText({
<AltTextReminder /> <AltTextReminder />
<Dialog.Outer <Dialog.Outer control={control} Portal={Portal}>
control={control}
nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
<Dialog.Handle /> <Dialog.Handle />
<AltTextInner <AltTextInner
onSubmit={onPressSubmit} onSubmit={onPressSubmit}
@@ -185,6 +186,8 @@ function AltTextInner({
</View> </View>
</View> </View>
<Dialog.Close /> <Dialog.Close />
{/* Maybe fix this later -h */}
{isAndroid ? <View style={{height: 300}} /> : null}
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
) )
} }
@@ -20,6 +20,7 @@ import {EditImageDialogProps} from './EditImageDialog'
export const EditImageDialog = (props: EditImageDialogProps) => { export const EditImageDialog = (props: EditImageDialogProps) => {
return ( return (
<Dialog.Outer control={props.control}> <Dialog.Outer control={props.control}>
<Dialog.Handle />
<EditImageInner key={props.image.source.id} {...props} /> <EditImageInner key={props.image.source.id} {...props} />
</Dialog.Outer> </Dialog.Outer>
) )
+12 -1
View File
@@ -21,6 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery'
import {Text} from '#/view/com/util/text/Text' import {Text} from '#/view/com/util/text/Text'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {PortalComponent} from '#/components/Portal'
import {ComposerAction} from '../state/composer' import {ComposerAction} from '../state/composer'
import {EditImageDialog} from './EditImageDialog' import {EditImageDialog} from './EditImageDialog'
import {ImageAltTextDialog} from './ImageAltTextDialog' import {ImageAltTextDialog} from './ImageAltTextDialog'
@@ -30,6 +31,7 @@ const IMAGE_GAP = 8
interface GalleryProps { interface GalleryProps {
images: ComposerImage[] images: ComposerImage[]
dispatch: (action: ComposerAction) => void dispatch: (action: ComposerAction) => void
Portal: PortalComponent
} }
export let Gallery = (props: GalleryProps): React.ReactNode => { export let Gallery = (props: GalleryProps): React.ReactNode => {
@@ -57,7 +59,12 @@ interface GalleryInnerProps extends GalleryProps {
containerInfo: Dimensions containerInfo: Dimensions
} }
const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => { const GalleryInner = ({
images,
containerInfo,
dispatch,
Portal,
}: GalleryInnerProps) => {
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const {altTextControlStyle, imageControlsStyle, imageStyle} = const {altTextControlStyle, imageControlsStyle, imageStyle} =
@@ -111,6 +118,7 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
onRemove={() => { onRemove={() => {
dispatch({type: 'embed_remove_image', image}) dispatch({type: 'embed_remove_image', image})
}} }}
Portal={Portal}
/> />
) )
})} })}
@@ -127,6 +135,7 @@ type GalleryItemProps = {
imageStyle?: ViewStyle imageStyle?: ViewStyle
onChange: (next: ComposerImage) => void onChange: (next: ComposerImage) => void
onRemove: () => void onRemove: () => void
Portal: PortalComponent
} }
const GalleryItem = ({ const GalleryItem = ({
@@ -136,6 +145,7 @@ const GalleryItem = ({
imageStyle, imageStyle,
onChange, onChange,
onRemove, onRemove,
Portal,
}: GalleryItemProps): React.ReactNode => { }: GalleryItemProps): React.ReactNode => {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
@@ -230,6 +240,7 @@ const GalleryItem = ({
control={altTextControl} control={altTextControl}
image={image} image={image}
onChange={onChange} onChange={onChange}
Portal={Portal}
/> />
<EditImageDialog <EditImageDialog
@@ -5,25 +5,26 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {MAX_ALT_TEXT} from '#/lib/constants' import {MAX_ALT_TEXT} from '#/lib/constants'
import {isWeb} from '#/platform/detection' import {isAndroid, isWeb} from '#/platform/detection'
import {ComposerImage} from '#/state/gallery' import {ComposerImage} from '#/state/gallery'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
type Props = { type Props = {
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
image: ComposerImage image: ComposerImage
onChange: (next: ComposerImage) => void onChange: (next: ComposerImage) => void
Portal: PortalComponent
} }
export const ImageAltTextDialog = (props: Props): React.ReactNode => { export const ImageAltTextDialog = (props: Props): React.ReactNode => {
return ( return (
<Dialog.Outer control={props.control}> <Dialog.Outer control={props.control} Portal={props.Portal}>
<Dialog.Handle /> <Dialog.Handle />
<ImageAltTextInner {...props} /> <ImageAltTextInner {...props} />
</Dialog.Outer> </Dialog.Outer>
) )
@@ -116,6 +117,8 @@ const ImageAltTextInner = ({
</ButtonText> </ButtonText>
</Button> </Button>
</View> </View>
{/* Maybe fix this later -h */}
{isAndroid ? <View style={{height: 300}} /> : null}
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
) )
} }
@@ -9,14 +9,16 @@ import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {GifSelectDialog} from '#/components/dialogs/GifSelect' import {GifSelectDialog} from '#/components/dialogs/GifSelect'
import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif' import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif'
import {PortalComponent} from '#/components/Portal'
type Props = { type Props = {
onClose: () => void onClose: () => void
onSelectGif: (gif: Gif) => void onSelectGif: (gif: Gif) => void
disabled?: boolean disabled?: boolean
Portal?: PortalComponent
} }
export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { export function SelectGifBtn({onClose, onSelectGif, disabled, Portal}: Props) {
const {_} = useLingui() const {_} = useLingui()
const ref = useRef<{open: () => void}>(null) const ref = useRef<{open: () => void}>(null)
const t = useTheme() const t = useTheme()
@@ -46,6 +48,7 @@ export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
controlRef={ref} controlRef={ref}
onClose={onClose} onClose={onClose}
onSelectGif={onSelectGif} onSelectGif={onSelectGif}
Portal={Portal}
/> />
</> </>
) )
@@ -9,6 +9,7 @@ import {isNative} from '#/platform/detection'
import {ComposerImage, createComposerImage} from '#/state/gallery' import {ComposerImage, createComposerImage} from '#/state/gallery'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image' import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
type Props = { type Props = {
@@ -21,23 +22,26 @@ export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
const {_} = useLingui() const {_} = useLingui()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const t = useTheme() const t = useTheme()
const sheetWrapper = useSheetWrapper()
const onPressSelectPhotos = useCallback(async () => { const onPressSelectPhotos = useCallback(async () => {
if (isNative && !(await requestPhotoAccessIfNeeded())) { if (isNative && !(await requestPhotoAccessIfNeeded())) {
return return
} }
const images = await openPicker({ const images = await sheetWrapper(
selectionLimit: 4 - size, openPicker({
allowsMultipleSelection: true, selectionLimit: 4 - size,
}) allowsMultipleSelection: true,
}),
)
const results = await Promise.all( const results = await Promise.all(
images.map(img => createComposerImage(img)), images.map(img => createComposerImage(img)),
) )
onAdd(results) onAdd(results)
}, [requestPhotoAccessIfNeeded, size, onAdd]) }, [requestPhotoAccessIfNeeded, size, onAdd, sheetWrapper])
return ( return (
<Button <Button
@@ -13,6 +13,7 @@ import * as Dialog from '#/components/Dialog'
import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog' import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {PortalComponent} from '#/components/Portal'
export function ThreadgateBtn({ export function ThreadgateBtn({
postgate, postgate,
@@ -20,6 +21,7 @@ export function ThreadgateBtn({
threadgateAllowUISettings, threadgateAllowUISettings,
onChangeThreadgateAllowUISettings, onChangeThreadgateAllowUISettings,
style, style,
Portal,
}: { }: {
postgate: AppBskyFeedPostgate.Record postgate: AppBskyFeedPostgate.Record
onChangePostgate: (v: AppBskyFeedPostgate.Record) => void onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
@@ -28,6 +30,8 @@ export function ThreadgateBtn({
onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
style?: StyleProp<AnimatedStyle<ViewStyle>> style?: StyleProp<AnimatedStyle<ViewStyle>>
Portal: PortalComponent
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
@@ -77,6 +81,7 @@ export function ThreadgateBtn({
onChangePostgate={onChangePostgate} onChangePostgate={onChangePostgate}
threadgateAllowUISettings={threadgateAllowUISettings} threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings} onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings}
Portal={Portal}
/> />
</> </>
) )
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
import {MAX_ALT_TEXT} from '#/lib/constants' import {MAX_ALT_TEXT} from '#/lib/constants'
import {useEnforceMaxGraphemeCount} from '#/lib/strings/helpers' import {useEnforceMaxGraphemeCount} from '#/lib/strings/helpers'
import {LANGUAGES} from '#/locale/languages' import {LANGUAGES} from '#/locale/languages'
import {isAndroid, isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {atoms as a, useTheme, web} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' 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 {PageText_Stroke2_Corner0_Rounded as PageTextIcon} from '#/components/icons/PageText'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {SubtitleFilePicker} from './SubtitleFilePicker' import {SubtitleFilePicker} from './SubtitleFilePicker'
@@ -29,6 +30,7 @@ interface Props {
captions: CaptionsTrack[] captions: CaptionsTrack[]
saveAltText: (altText: string) => void saveAltText: (altText: string) => void
setCaptions: (updater: (prev: CaptionsTrack[]) => CaptionsTrack[]) => void setCaptions: (updater: (prev: CaptionsTrack[]) => CaptionsTrack[]) => void
Portal: PortalComponent
} }
export function SubtitleDialogBtn(props: Props) { export function SubtitleDialogBtn(props: Props) {
@@ -56,9 +58,7 @@ export function SubtitleDialogBtn(props: Props) {
{isWeb ? <Trans>Captions & alt text</Trans> : <Trans>Alt text</Trans>} {isWeb ? <Trans>Captions & alt text</Trans> : <Trans>Alt text</Trans>}
</ButtonText> </ButtonText>
</Button> </Button>
<Dialog.Outer <Dialog.Outer control={control} Portal={props.Portal}>
control={control}
nativeOptions={isAndroid ? {sheet: {snapPoints: ['60%']}} : {}}>
<Dialog.Handle /> <Dialog.Handle />
<SubtitleDialogInner {...props} /> <SubtitleDialogInner {...props} />
</Dialog.Outer> </Dialog.Outer>
+8 -4
View File
@@ -20,6 +20,7 @@ import {isAndroid, isNative, isWeb} from '#/platform/detection'
import {precacheProfile} from '#/state/queries/profile' import {precacheProfile} from '#/state/queries/profile'
import {HighPriorityImage} from '#/view/com/util/images/Image' import {HighPriorityImage} from '#/view/com/util/images/Image'
import {tokens, useTheme} from '#/alf' import {tokens, useTheme} from '#/alf'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import { import {
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
Camera_Stroke2_Corner0_Rounded as Camera, Camera_Stroke2_Corner0_Rounded as Camera,
@@ -271,6 +272,7 @@ let EditableUserAvatar = ({
const {_} = useLingui() const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestCameraAccessIfNeeded} = useCameraPermission()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const sheetWrapper = useSheetWrapper()
const aviStyle = useMemo(() => { const aviStyle = useMemo(() => {
if (type === 'algo' || type === 'list') { if (type === 'algo' || type === 'list') {
@@ -306,9 +308,11 @@ let EditableUserAvatar = ({
return return
} }
const items = await openPicker({ const items = await sheetWrapper(
aspect: [1, 1], openPicker({
}) aspect: [1, 1],
}),
)
const item = items[0] const item = items[0]
if (!item) { if (!item) {
return return
@@ -332,7 +336,7 @@ let EditableUserAvatar = ({
logger.error('Failed to crop banner', {error: e}) logger.error('Failed to crop banner', {error: e})
} }
} }
}, [onSelectNewAvatar, requestPhotoAccessIfNeeded]) }, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper])
const onRemoveAvatar = React.useCallback(() => { const onRemoveAvatar = React.useCallback(() => {
onSelectNewAvatar(null) onSelectNewAvatar(null)
+4 -2
View File
@@ -17,6 +17,7 @@ import {logger} from '#/logger'
import {isAndroid, isNative} from '#/platform/detection' import {isAndroid, isNative} from '#/platform/detection'
import {EventStopper} from '#/view/com/util/EventStopper' import {EventStopper} from '#/view/com/util/EventStopper'
import {tokens, useTheme as useAlfTheme} from '#/alf' import {tokens, useTheme as useAlfTheme} from '#/alf'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import { import {
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
Camera_Stroke2_Corner0_Rounded as Camera, Camera_Stroke2_Corner0_Rounded as Camera,
@@ -43,6 +44,7 @@ export function UserBanner({
const {_} = useLingui() const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestCameraAccessIfNeeded} = useCameraPermission()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const sheetWrapper = useSheetWrapper()
const onOpenCamera = React.useCallback(async () => { const onOpenCamera = React.useCallback(async () => {
if (!(await requestCameraAccessIfNeeded())) { if (!(await requestCameraAccessIfNeeded())) {
@@ -60,7 +62,7 @@ export function UserBanner({
if (!(await requestPhotoAccessIfNeeded())) { if (!(await requestPhotoAccessIfNeeded())) {
return return
} }
const items = await openPicker() const items = await sheetWrapper(openPicker())
if (!items[0]) { if (!items[0]) {
return return
} }
@@ -80,7 +82,7 @@ export function UserBanner({
logger.error('Failed to crop banner', {error: e}) logger.error('Failed to crop banner', {error: e})
} }
} }
}, [onSelectNewBanner, requestPhotoAccessIfNeeded]) }, [onSelectNewBanner, requestPhotoAccessIfNeeded, sheetWrapper])
const onRemoveBanner = React.useCallback(() => { const onRemoveBanner = React.useCallback(() => {
onSelectNewBanner?.(null) onSelectNewBanner?.(null)
+9 -7
View File
@@ -240,8 +240,8 @@ let PostDropdownBtn = ({
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}, [_, richText]) }, [_, richText])
const onPressTranslate = React.useCallback(() => { const onPressTranslate = React.useCallback(async () => {
openLink(translatorUrl) await openLink(translatorUrl)
}, [openLink, translatorUrl]) }, [openLink, translatorUrl])
const onHidePost = React.useCallback(() => { const onHidePost = React.useCallback(() => {
@@ -439,7 +439,7 @@ let PostDropdownBtn = ({
<Menu.Item <Menu.Item
testID="postDropdownSendViaDMBtn" testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)} label={_(msg`Send via direct message`)}
onPress={sendViaChatControl.open}> onPress={() => sendViaChatControl.open()}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Send via direct message</Trans> <Trans>Send via direct message</Trans>
</Menu.ItemText> </Menu.ItemText>
@@ -467,7 +467,7 @@ let PostDropdownBtn = ({
<Menu.Item <Menu.Item
testID="postDropdownEmbedBtn" testID="postDropdownEmbedBtn"
label={_(msg`Embed post`)} label={_(msg`Embed post`)}
onPress={embedPostControl.open}> onPress={() => embedPostControl.open()}>
<Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText> <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
<Menu.ItemIcon icon={CodeBrackets} position="right" /> <Menu.ItemIcon icon={CodeBrackets} position="right" />
</Menu.Item> </Menu.Item>
@@ -542,7 +542,7 @@ let PostDropdownBtn = ({
? _(msg`Hide reply for me`) ? _(msg`Hide reply for me`)
: _(msg`Hide post for me`) : _(msg`Hide post for me`)
} }
onPress={hidePromptControl.open}> onPress={() => hidePromptControl.open()}>
<Menu.ItemText> <Menu.ItemText>
{isReply {isReply
? _(msg`Hide reply for me`) ? _(msg`Hide reply for me`)
@@ -630,7 +630,9 @@ let PostDropdownBtn = ({
<Menu.Item <Menu.Item
testID="postDropdownEditPostInteractions" testID="postDropdownEditPostInteractions"
label={_(msg`Edit interaction settings`)} label={_(msg`Edit interaction settings`)}
onPress={postInteractionSettingsDialogControl.open} onPress={() =>
postInteractionSettingsDialogControl.open()
}
{...(isAuthor {...(isAuthor
? Platform.select({ ? Platform.select({
web: { web: {
@@ -649,7 +651,7 @@ let PostDropdownBtn = ({
<Menu.Item <Menu.Item
testID="postDropdownDeleteBtn" testID="postDropdownDeleteBtn"
label={_(msg`Delete post`)} label={_(msg`Delete post`)}
onPress={deletePromptControl.open}> onPress={() => deletePromptControl.open()}>
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" /> <Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item> </Menu.Item>
@@ -86,7 +86,9 @@ let RepostButton = ({
</Text> </Text>
) : undefined} ) : undefined}
</Button> </Button>
<Dialog.Outer control={dialogControl}> <Dialog.Outer
control={dialogControl}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.Inner label={_(msg`Repost or quote post`)}> <Dialog.Inner label={_(msg`Repost or quote post`)}>
<View style={a.gap_xl}> <View style={a.gap_xl}>
@@ -155,7 +157,6 @@ let RepostButton = ({
</View> </View>
<Button <Button
label={_(msg`Cancel quote post`)} label={_(msg`Cancel quote post`)}
onAccessibilityEscape={close}
onPress={close} onPress={close}
size="large" size="large"
variant="solid" variant="solid"
@@ -79,7 +79,6 @@ export function DisableEmail2FADialog({
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner <Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description" accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title"> accessibilityLabelledBy="dialog-title">
@@ -53,7 +53,6 @@ export function ExportCarDialog({
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner <Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description" accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title"> accessibilityLabelledBy="dialog-title">
+2 -12
View File
@@ -2,8 +2,8 @@ import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {useDialogStateControlContext} from '#/state/dialogs' import {useDialogStateControlContext} from '#/state/dialogs'
import {NavigationProp} from 'lib/routes/types'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
@@ -179,19 +179,13 @@ export function Dialogs() {
</Prompt.Outer> </Prompt.Outer>
<Dialog.Outer control={basic}> <Dialog.Outer control={basic}>
<Dialog.Handle />
<Dialog.Inner label="test"> <Dialog.Inner label="test">
<H3 nativeID="dialog-title">Dialog</H3> <H3 nativeID="dialog-title">Dialog</H3>
<P nativeID="dialog-description">A basic dialog</P> <P nativeID="dialog-description">A basic dialog</P>
</Dialog.Inner> </Dialog.Inner>
</Dialog.Outer> </Dialog.Outer>
<Dialog.Outer <Dialog.Outer control={scrollable}>
control={scrollable}
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<Dialog.Handle />
<Dialog.ScrollableInner <Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description" accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title"> accessibilityLabelledBy="dialog-title">
@@ -230,8 +224,6 @@ export function Dialogs() {
</Dialog.Outer> </Dialog.Outer>
<Dialog.Outer control={testDialog}> <Dialog.Outer control={testDialog}>
<Dialog.Handle />
<Dialog.ScrollableInner <Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description" accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title"> accessibilityLabelledBy="dialog-title">
@@ -356,8 +348,6 @@ export function Dialogs() {
{shouldRenderUnmountTest && ( {shouldRenderUnmountTest && (
<Dialog.Outer control={unmountTestDialog}> <Dialog.Outer control={unmountTestDialog}>
<Dialog.Handle />
<Dialog.Inner label="test"> <Dialog.Inner label="test">
<H3 nativeID="dialog-title">Unmount Test Dialog</H3> <H3 nativeID="dialog-title">Unmount Test Dialog</H3>
<P nativeID="dialog-description">Will unmount in about 5 seconds</P> <P nativeID="dialog-description">Will unmount in about 5 seconds</P>
+24 -53
View File
@@ -1,19 +1,28 @@
import React, {useLayoutEffect} from 'react' import React from 'react'
import {Modal, View} from 'react-native' 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 {useComposerState} from '#/state/shell/composer'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {getBackgroundColor, useThemeName} from '#/alf/util/useColorModeTheme'
import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' import {ComposePost, useComposerCancelRef} from '../com/composer/Composer'
export function Composer({}: {winHeight: number}) { export function Composer({}: {winHeight: number}) {
const {setFullyExpandedCount} = useDialogStateControlContext()
const t = useTheme() const t = useTheme()
const state = useComposerState() const state = useComposerState()
const ref = useComposerCancelRef() const ref = useComposerCancelRef()
const open = !!state 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 ( return (
<Modal <Modal
@@ -24,56 +33,18 @@ export function Composer({}: {winHeight: number}) {
animationType="slide" animationType="slide"
onRequestClose={() => ref.current?.onPressCancel()}> onRequestClose={() => ref.current?.onPressCancel()}>
<View style={[t.atoms.bg, a.flex_1]}> <View style={[t.atoms.bg, a.flex_1]}>
<Providers open={open}> <ComposePost
<ComposePost cancelRef={ref}
cancelRef={ref} replyTo={state?.replyTo}
replyTo={state?.replyTo} onPost={state?.onPost}
onPost={state?.onPost} quote={state?.quote}
quote={state?.quote} quoteCount={state?.quoteCount}
quoteCount={state?.quoteCount} mention={state?.mention}
mention={state?.mention} text={state?.text}
text={state?.text} imageUris={state?.imageUris}
imageUris={state?.imageUris} videoUri={state?.videoUri}
videoUri={state?.videoUri} />
/>
</Providers>
</View> </View>
</Modal> </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
}
+21 -16
View File
@@ -13,6 +13,14 @@ import * as NavigationBar from 'expo-navigation-bar'
import {StatusBar} from 'expo-status-bar' import {StatusBar} from 'expo-status-bar'
import {useNavigation, useNavigationState} from '@react-navigation/native' 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 {useSession} from '#/state/session'
import { import {
useIsDrawerOpen, useIsDrawerOpen,
@@ -20,17 +28,9 @@ import {
useSetDrawerOpen, useSetDrawerOpen,
} from '#/state/shell' } from '#/state/shell'
import {useCloseAnyActiveElement} from '#/state/util' import {useCloseAnyActiveElement} from '#/state/util'
import {useDedupe} from 'lib/hooks/useDedupe' import {Lightbox} from '#/view/com/lightbox/Lightbox'
import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler' import {ModalsContainer} from '#/view/com/modals/Modal'
import {usePalette} from 'lib/hooks/usePalette' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
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 {MutedWordsDialog} from '#/components/dialogs/MutedWords' import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin' import {SigninDialog} from '#/components/dialogs/Signin'
import {Outlet as PortalOutlet} from '#/components/Portal' import {Outlet as PortalOutlet} from '#/components/Portal'
@@ -61,7 +61,6 @@ function ShellInner() {
const canGoBack = useNavigationState(state => !isStateAtTabRoot(state)) const canGoBack = useNavigationState(state => !isStateAtTabRoot(state))
const {hasSession} = useSession() const {hasSession} = useSession()
const closeAnyActiveElement = useCloseAnyActiveElement() const closeAnyActiveElement = useCloseAnyActiveElement()
const {importantForAccessibility} = useDialogStateContext()
useNotificationsRegistration() useNotificationsRegistration()
useNotificationsHandler() useNotificationsHandler()
@@ -101,9 +100,7 @@ function ShellInner() {
return ( return (
<> <>
<Animated.View <Animated.View style={containerPadding}>
style={containerPadding}
importantForAccessibility={importantForAccessibility}>
<ErrorBoundary> <ErrorBoundary>
<Drawer <Drawer
renderDrawerContent={renderDrawerContent} renderDrawerContent={renderDrawerContent}
@@ -127,6 +124,7 @@ function ShellInner() {
} }
export const Shell: React.FC = function ShellImpl() { export const Shell: React.FC = function ShellImpl() {
const {fullyExpandedCount} = useDialogStateControlContext()
const pal = usePalette('default') const pal = usePalette('default')
const theme = useTheme() const theme = useTheme()
React.useEffect(() => { React.useEffect(() => {
@@ -140,7 +138,14 @@ export const Shell: React.FC = function ShellImpl() {
}, [theme]) }, [theme])
return ( return (
<View testID="mobileShellView" style={[styles.outerContainer, pal.view]}> <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> <RoutesContainer>
<ShellInner /> <ShellInner />
</RoutesContainer> </RoutesContainer>
+8 -17
View File
@@ -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" resolved "https://registry.yarnpkg.com/expo-structured-headers/-/expo-structured-headers-3.8.0.tgz#11797a4c3a7a6770b21126cecffcda148030e361"
integrity sha512-R+gFGn0x5CWl4OVlk2j1bJTJIz4KO8mPoCHpRHmfqMjmrMvrOM0qQSY3V5NHXwp1yT/L2v8aUmFQsBRIdvi1XA== 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: expo-task-manager@~11.8.1:
version "11.8.1" version "11.8.1"
resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-11.8.1.tgz#33089e78ee3fbd83327fb403bce12d69baf7d21b" 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: dependencies:
use-latest-callback "^0.1.9" use-latest-callback "^0.1.9"
react-native-gesture-handler@~2.16.2: react-native-gesture-handler@2.20.0:
version "2.16.2" version "2.20.0"
resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.16.2.tgz#032bd2a07334292d7f6cff1dc9d1ec928f72e26d" resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.20.0.tgz#2d9ec4e9bd22619ebe36269dda3ecb1173928276"
integrity sha512-vGFlrDKlmyI+BT+FemqVxmvO7nqxU33cgXVsn6IKAFishvlG3oV2Ds67D5nPkHMea8T+s1IcuMm0bF8ntZtAyg== integrity sha512-rFKqgHRfxQ7uSAivk8vxCiW4SB3G0U7jnv7kZD4Y90K5kp6YrU8Q3tWhxe3Rx55BIvSd3mBe9ZWbWVJ0FsSHPA==
dependencies: dependencies:
"@egjs/hammerjs" "^2.0.17" "@egjs/hammerjs" "^2.0.17"
hoist-non-react-statics "^3.3.0" hoist-non-react-statics "^3.3.0"
invariant "^2.2.4" invariant "^2.2.4"
lodash "^4.17.21"
prop-types "^15.7.2" prop-types "^15.7.2"
react-native-get-random-values@^1.6.0: react-native-get-random-values@^1.6.0:
@@ -18094,10 +18085,10 @@ react-native-ios-context-menu@^1.15.3:
dependencies: dependencies:
"@dominicstop/ts-event-emitter" "^1.1.0" "@dominicstop/ts-event-emitter" "^1.1.0"
react-native-keyboard-controller@^1.12.1: react-native-keyboard-controller@^1.14.0:
version "1.12.1" version "1.14.0"
resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.1.tgz#6de22ed4d060528a0dd25621eeaa7f71772ce35f" resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.14.0.tgz#f6faaa12b3736a10f4eec4236ed5b0343508b9a1"
integrity sha512-2OpQcesiYsMilrTzgcTafSGexd9UryRQRuHudIcOn0YaqvvzNpnhVZMVuJMH93fJv/iaZYp3138rgUKOdHhtSw== integrity sha512-JW9k2fehFXOpvLWh1YcgyubLodg/HPi6bR11sCZB/BOawf1tnbGnqk967B8XkxDOKHH6mg+z82quCvv8ALh1rg==
react-native-mmkv@^2.12.2: react-native-mmkv@^2.12.2:
version "2.12.2" version "2.12.2"