Merge branch 'main' into static_asset_cdn

This commit is contained in:
Jaz Volpert
2024-10-04 14:28:52 -07:00
100 changed files with 3016 additions and 1932 deletions
+14
View File
@@ -24,6 +24,20 @@
*
* THIS NEEDS TO BE DUPLICATED IN `bskyweb/templates/base.html`
*/
@font-face {
font-family: 'InterVariable';
src: url(/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf) format('truetype');
font-weight: 300 1000;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'InterVariableItalic';
src: url(/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf) format('truetype');
font-weight: 300 1000;
font-style: italic;
font-display: swap;
}
html {
background-color: white;
scrollbar-gutter: stable both-edges;
+5
View File
@@ -96,6 +96,11 @@ jest.mock('expo-modules-core', () => ({
getIsReducedMotionEnabled: () => false,
}
}
if (moduleName === 'BottomSheet') {
return {
dismissAll: () => {},
}
}
}),
requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
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-splash-screen": "~0.27.4",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14",
"expo-web-browser": "~13.0.3",
@@ -171,11 +170,11 @@
"react-native-compressor": "^1.8.24",
"react-native-date-picker": "^4.4.2",
"react-native-drawer-layout": "^4.0.0-alpha.3",
"react-native-gesture-handler": "~2.16.2",
"react-native-gesture-handler": "2.20.0",
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "0.41.2",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.12.1",
"react-native-keyboard-controller": "^1.14.0",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.2.3",
"react-native-picker-select": "^9.1.3",
+63 -17
View File
@@ -38,37 +38,65 @@ index b0d71dc..41b9a0e 100644
- (void)reactBlur
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
index e9b330f..1ecdf0a 100644
index e9b330f..ec5f58c 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
@@ -16,4 +16,6 @@
@@ -15,5 +15,8 @@
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
@property (nonatomic, weak) UIScrollView *scrollView;
+- (void)forwarderBeginRefreshing;
+@property (nonatomic, copy) UIColor *customTintColor;
+
+- (void)forwarderBeginRefreshing;
@end
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
index b09e653..f93cb46 100644
index b09e653..288e60c 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
@@ -198,9 +198,53 @@ - (void)refreshControlValueChanged
[self setCurrentRefreshingState:super.refreshing];
_refreshingProgrammatically = NO;
@@ -22,6 +22,7 @@ @implementation RCTRefreshControl {
NSString *_title;
UIColor *_titleColor;
CGFloat _progressViewOffset;
+ UIColor *_customTintColor;
}
+ if (@available(iOS 17.4, *)) {
+ if (_currentRefreshingState) {
+ UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
+ [feedbackGenerator prepare];
+ [feedbackGenerator impactOccurred];
+ }
+ }
- (instancetype)init
@@ -56,6 +57,12 @@ - (void)layoutSubviews
_isInitialRender = false;
}
+- (void)didMoveToSuperview
+{
+ [super didMoveToSuperview];
+ [self setTintColor:_customTintColor];
+}
+
if (_onRefresh) {
_onRefresh(nil);
- (void)beginRefreshingProgrammatically
{
UInt64 beginRefreshingTimestamp = _currentRefreshingStateTimestamp;
@@ -203,4 +210,58 @@ - (void)refreshControlValueChanged
}
}
+- (void)setCustomTintColor:(UIColor *)customTintColor
+{
+ _customTintColor = customTintColor;
+ [self setTintColor:customTintColor];
+}
+
+// Fix for https://github.com/facebook/react-native/issues/43388
+// A bug in iOS 17.4 causes the haptic to not play when refreshing if the tintColor
+// is set before the refresh control gets added to the scrollview. We'll call this
+// function whenever the superview changes. We'll also call it if the value of customTintColor
+// changes.
+- (void)setTintColor:(UIColor *)tintColor
+{
+ if ([self.superview isKindOfClass:[UIScrollView class]] && self.tintColor != tintColor) {
+ [super setTintColor:tintColor];
+ }
+}
+
+/*
+ This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
+ libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
@@ -106,6 +134,24 @@ index b09e653..f93cb46 100644
+}
+
@end
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
index 40aaf9c..1c60164 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
@@ -22,11 +22,12 @@ - (UIView *)view
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
-RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(title, NSString)
RCT_EXPORT_VIEW_PROPERTY(titleColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(progressViewOffset, CGFloat)
+RCT_REMAP_VIEW_PROPERTY(tintColor, customTintColor, UIColor)
+
RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing)
{
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java
index 5f5e1ab..aac00b6 100644
--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java
+2 -2
View File
@@ -1,6 +1,6 @@
import 'react-native-url-polyfill/auto'
import 'lib/sentry' // must be near top
import 'view/icons'
import '#/lib/sentry' // must be near top
import '#/view/icons'
import React, {useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
+2
View File
@@ -70,6 +70,8 @@ export function applyFonts(
* IMPORTANT: This is unused. Expo statically extracts these fonts.
*
* All used fonts MUST be configured here. Unused fonts can be commented out.
*
* This is used for both web fonts and native fonts.
*/
export function DO_NOT_USE() {
return useFonts({
+2 -4
View File
@@ -1,9 +1,8 @@
import React from 'react'
import {ColorSchemeName, useColorScheme} from 'react-native'
import * as SystemUI from 'expo-system-ui'
import {isWeb} from 'platform/detection'
import {useThemePrefs} from 'state/shell'
import {isWeb} from '#/platform/detection'
import {useThemePrefs} from '#/state/shell'
import {dark, dim, light} from '#/alf/themes'
import {ThemeName} from '#/alf/types'
@@ -12,7 +11,6 @@ export function useColorModeTheme(): ThemeName {
React.useLayoutEffect(() => {
updateDocument(theme)
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
}, [theme])
return theme
+5 -2
View File
@@ -87,6 +87,7 @@ export type ButtonProps = Pick<
style?: StyleProp<ViewStyle>
hoverStyle?: StyleProp<ViewStyle>
children: NonTextElements | ((context: ButtonContext) => NonTextElements)
PressableComponent?: React.ComponentType<PressableProps>
}
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
@@ -114,6 +115,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
disabled = false,
style,
hoverStyle: hoverStyleProp,
PressableComponent = Pressable,
...rest
},
ref,
@@ -449,10 +451,11 @@ export const Button = React.forwardRef<View, ButtonProps>(
const flattenedBaseStyles = flatten([baseStyles, style])
return (
<Pressable
<PressableComponent
role="button"
accessibilityHint={undefined} // optional
{...rest}
// @ts-ignore - this will always be a pressable
ref={ref}
aria-label={label}
aria-pressed={state.pressed}
@@ -500,7 +503,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
<Context.Provider value={context}>
{typeof children === 'function' ? children(context) : children}
</Context.Provider>
</Pressable>
</PressableComponent>
)
},
)
+5
View File
@@ -6,9 +6,14 @@ import {
DialogControlRefProps,
DialogOuterProps,
} from '#/components/Dialog/types'
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
export const Context = React.createContext<DialogContextProps>({
close: () => {},
isNativeDialog: false,
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
disableDrag: false,
setDisableDrag: () => {},
})
export function useDialogContext() {
+173 -207
View File
@@ -1,86 +1,48 @@
import React, {useImperativeHandle} from 'react'
import {
Dimensions,
Keyboard,
NativeScrollEvent,
NativeSyntheticEvent,
Pressable,
ScrollView,
StyleProp,
TextInput,
View,
ViewStyle,
} from 'react-native'
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {
KeyboardAwareScrollView,
useKeyboardHandler,
} from 'react-native-keyboard-controller'
import {runOnJS} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import BottomSheet, {
BottomSheetBackdropProps,
BottomSheetFlatList,
BottomSheetFlatListMethods,
BottomSheetScrollView,
BottomSheetScrollViewMethods,
BottomSheetTextInput,
BottomSheetView,
useBottomSheet,
WINDOW_HEIGHT,
} from '@discord/bottom-sheet/src'
import {BottomSheetFlatListProps} from '@discord/bottom-sheet/src/components/bottomSheetScrollable/types'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs'
import {atoms as a, flatten, useTheme} from '#/alf'
import {Context} from '#/components/Dialog/context'
import {List, ListMethods, ListProps} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {Context, useDialogContext} from '#/components/Dialog/context'
import {
DialogControlProps,
DialogInnerProps,
DialogOuterProps,
} from '#/components/Dialog/types'
import {createInput} from '#/components/forms/TextField'
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
import {Portal} from '#/components/Portal'
import {Portal as DefaultPortal} from '#/components/Portal'
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
import {
BottomSheetSnapPointChangeEvent,
BottomSheetStateChangeEvent,
} from '../../../modules/bottom-sheet/src/BottomSheet.types'
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
export * from '#/components/Dialog/utils'
// @ts-ignore
export const Input = createInput(BottomSheetTextInput)
function Backdrop(props: BottomSheetBackdropProps) {
const t = useTheme()
const bottomSheet = useBottomSheet()
const animatedStyle = useAnimatedStyle(() => {
const opacity =
(Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000
return {
opacity: Math.min(Math.max(opacity, 0), 0.55),
}
})
const onPress = React.useCallback(() => {
bottomSheet.close()
}, [bottomSheet])
return (
<Animated.View
style={[
t.atoms.bg_contrast_300,
{
top: 0,
left: 0,
right: 0,
bottom: 0,
position: 'absolute',
},
animatedStyle,
]}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Dialog backdrop"
accessibilityHint="Press the backdrop to close the dialog"
style={{flex: 1}}
onPress={onPress}
/>
</Animated.View>
)
}
export const Input = createInput(TextInput)
export function Outer({
children,
@@ -88,24 +50,22 @@ export function Outer({
onClose,
nativeOptions,
testID,
Portal = DefaultPortal,
}: React.PropsWithChildren<DialogOuterProps>) {
const t = useTheme()
const sheet = React.useRef<BottomSheet>(null)
const sheetOptions = nativeOptions?.sheet || {}
const hasSnapPoints = !!sheetOptions.snapPoints
const insets = useSafeAreaInsets()
const ref = React.useRef<BottomSheet>(null)
const closeCallbacks = React.useRef<(() => void)[]>([])
const {setDialogIsOpen} = useDialogStateControlContext()
const {setDialogIsOpen, setFullyExpandedCount} =
useDialogStateControlContext()
/*
* Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
*/
const [openIndex, setOpenIndex] = React.useState(-1)
const prevSnapPoint = React.useRef<BottomSheetSnapPoint>(
BottomSheetSnapPoint.Hidden,
)
/*
* `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open.
*/
const isOpen = openIndex > -1
const [disableDrag, setDisableDrag] = React.useState(false)
const [snapPoint, setSnapPoint] = React.useState<BottomSheetSnapPoint>(
BottomSheetSnapPoint.Partial,
)
const callQueuedCallbacks = React.useCallback(() => {
for (const cb of closeCallbacks.current) {
@@ -119,25 +79,19 @@ export function Outer({
closeCallbacks.current = []
}, [])
const open = React.useCallback<DialogControlProps['open']>(
({index} = {}) => {
// Run any leftover callbacks that might have been queued up before calling `.open()`
callQueuedCallbacks()
setDialogIsOpen(control.id, true)
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
setOpenIndex(index || 0)
sheet.current?.snapToIndex(index || 0)
},
[setDialogIsOpen, control.id, callQueuedCallbacks],
)
const open = React.useCallback<DialogControlProps['open']>(() => {
// Run any leftover callbacks that might have been queued up before calling `.open()`
callQueuedCallbacks()
setDialogIsOpen(control.id, true)
ref.current?.present()
}, [setDialogIsOpen, control.id, callQueuedCallbacks])
// This is the function that we call when we want to dismiss the dialog.
const close = React.useCallback<DialogControlProps['close']>(cb => {
if (typeof cb === 'function') {
closeCallbacks.current.push(cb)
}
sheet.current?.close()
ref.current?.dismiss()
}, [])
// This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to
@@ -146,12 +100,39 @@ export function Outer({
// This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this
// tells us that we need to toggle the accessibility overlay setting
setDialogIsOpen(control.id, false)
setOpenIndex(-1)
callQueuedCallbacks()
onClose?.()
}, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen])
const onSnapPointChange = (e: BottomSheetSnapPointChangeEvent) => {
const {snapPoint} = e.nativeEvent
setSnapPoint(snapPoint)
if (
snapPoint === BottomSheetSnapPoint.Full &&
prevSnapPoint.current !== BottomSheetSnapPoint.Full
) {
setFullyExpandedCount(c => c + 1)
} else if (
snapPoint !== BottomSheetSnapPoint.Full &&
prevSnapPoint.current === BottomSheetSnapPoint.Full
) {
setFullyExpandedCount(c => c - 1)
}
prevSnapPoint.current = snapPoint
}
const onStateChange = (e: BottomSheetStateChangeEvent) => {
if (e.nativeEvent.state === 'closed') {
onCloseAnimationComplete()
if (prevSnapPoint.current === BottomSheetSnapPoint.Full) {
setFullyExpandedCount(c => c - 1)
}
prevSnapPoint.current = BottomSheetSnapPoint.Hidden
}
}
useImperativeHandle(
control.ref,
() => ({
@@ -161,159 +142,144 @@ export function Outer({
[open, close],
)
React.useEffect(() => {
return () => {
setDialogIsOpen(control.id, false)
}
}, [control.id, setDialogIsOpen])
const context = React.useMemo(() => ({close}), [close])
const context = React.useMemo(
() => ({
close,
isNativeDialog: true,
nativeSnapPoint: snapPoint,
disableDrag,
setDisableDrag,
}),
[close, snapPoint, disableDrag, setDisableDrag],
)
return (
isOpen && (
<Portal>
<FullWindowOverlay>
<View
// iOS
accessibilityViewIsModal
// Android
importantForAccessibility="yes"
style={[a.absolute, a.inset_0]}
testID={testID}
onTouchMove={() => Keyboard.dismiss()}>
<BottomSheet
enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose
keyboardBehavior="interactive"
android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore"
topInset={insets.top}
{...sheetOptions}
snapPoints={sheetOptions.snapPoints || ['100%']}
ref={sheet}
index={openIndex}
backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={Backdrop}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onClose={onCloseAnimationComplete}>
<Context.Provider value={context}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
},
]}
/>
{children}
</Context.Provider>
</BottomSheet>
</View>
</FullWindowOverlay>
</Portal>
)
<Portal>
<Context.Provider value={context}>
<BottomSheet
ref={ref}
cornerRadius={20}
backgroundColor={t.atoms.bg.backgroundColor}
{...nativeOptions}
onSnapPointChange={onSnapPointChange}
onStateChange={onStateChange}
disableDrag={disableDrag}>
<View testID={testID}>{children}</View>
</BottomSheet>
</Context.Provider>
</Portal>
)
}
export function Inner({children, style}: DialogInnerProps) {
const insets = useSafeAreaInsets()
return (
<BottomSheetView
<View
style={[
a.py_xl,
a.pt_2xl,
a.px_xl,
{
paddingTop: 40,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
paddingBottom: insets.bottom + insets.top,
},
flatten(style),
style,
]}>
{children}
</BottomSheetView>
</View>
)
}
export const ScrollableInner = React.forwardRef<
BottomSheetScrollViewMethods,
DialogInnerProps
>(function ScrollableInner({children, style}, ref) {
const insets = useSafeAreaInsets()
return (
<BottomSheetScrollView
keyboardShouldPersistTaps="handled"
style={[
a.flex_1, // main diff is this
a.p_xl,
a.h_full,
{
paddingTop: 40,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
},
style,
]}
contentContainerStyle={a.pb_4xl}
ref={ref}>
{children}
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
</BottomSheetScrollView>
)
})
export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
function ScrollableInner({children, style, ...props}, ref) {
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const insets = useSafeAreaInsets()
const [keyboardHeight, setKeyboardHeight] = React.useState(0)
useKeyboardHandler({
onEnd: e => {
'worklet'
runOnJS(setKeyboardHeight)(e.height)
},
})
const basePading =
(isIOS ? 30 : 50) + (isIOS ? keyboardHeight / 4 : keyboardHeight)
const fullPaddingBase = insets.bottom + insets.top + basePading
const fullPadding = isIOS ? fullPaddingBase : fullPaddingBase + 50
const paddingBottom =
nativeSnapPoint === BottomSheetSnapPoint.Full ? fullPadding : basePading
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const {contentOffset} = e.nativeEvent
if (contentOffset.y > 0 && !disableDrag) {
setDisableDrag(true)
} else if (contentOffset.y <= 1 && disableDrag) {
setDisableDrag(false)
}
}
return (
<KeyboardAwareScrollView
style={[style]}
contentContainerStyle={[a.pt_2xl, a.px_xl, {paddingBottom}]}
ref={ref}
{...props}
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
bottomOffset={30}
scrollEventThrottle={50}
onScroll={isAndroid ? onScroll : undefined}>
{children}
</KeyboardAwareScrollView>
)
},
)
export const InnerFlatList = React.forwardRef<
BottomSheetFlatListMethods,
BottomSheetFlatListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
>(function InnerFlatList({style, contentContainerStyle, ...props}, ref) {
ListMethods,
ListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
>(function InnerFlatList({style, ...props}, ref) {
const insets = useSafeAreaInsets()
const {nativeSnapPoint} = useDialogContext()
return (
<BottomSheetFlatList
<List
keyboardShouldPersistTaps="handled"
contentContainerStyle={[a.pb_4xl, flatten(contentContainerStyle)]}
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
ListFooterComponent={
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
}
ref={ref}
{...props}
style={[
a.flex_1,
a.p_xl,
a.pt_0,
a.h_full,
{
marginTop: 40,
},
flatten(style),
]}
style={[style]}
/>
)
})
export function Handle() {
const t = useTheme()
const {_} = useLingui()
const {screenReaderEnabled} = useA11y()
const {close} = useDialogContext()
return (
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
<View
style={[
a.rounded_sm,
{
top: a.pt_lg.paddingTop,
width: 35,
height: 4,
alignSelf: 'center',
backgroundColor: t.palette.contrast_900,
opacity: 0.5,
},
]}
/>
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 20}]}>
<Pressable
accessible={screenReaderEnabled}
onPress={() => close()}
accessibilityLabel={_(msg`Dismiss`)}
accessibilityHint={_(msg`Double tap to close the dialog`)}>
<View
style={[
a.rounded_sm,
{
top: 10,
width: 35,
height: 5,
alignSelf: 'center',
backgroundColor: t.palette.contrast_975,
opacity: 0.5,
},
]}
/>
</Pressable>
</View>
)
}
+8 -4
View File
@@ -103,6 +103,10 @@ export function Outer({
const context = React.useMemo(
() => ({
close,
isNativeDialog: false,
nativeSnapPoint: 0,
disableDrag: false,
setDisableDrag: () => {},
}),
[close],
)
@@ -229,10 +233,6 @@ export const InnerFlatList = React.forwardRef<
)
})
export function Handle() {
return null
}
export function Close() {
const {_} = useLingui()
const {close} = React.useContext(Context)
@@ -258,3 +258,7 @@ export function Close() {
</View>
)
}
export function Handle() {
return null
}
+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,
ScrollViewProps,
} from 'react-native'
import {BottomSheetProps} from '@discord/bottom-sheet/src'
import {ViewStyleProp} from '#/alf'
import {PortalComponent} from '#/components/Portal'
import {BottomSheetViewProps} from '../../../modules/bottom-sheet'
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
type A11yProps = Required<AccessibilityProps>
@@ -37,6 +39,10 @@ export type DialogControlProps = DialogControlRefProps & {
export type DialogContextProps = {
close: DialogControlProps['close']
isNativeDialog: boolean
nativeSnapPoint: BottomSheetSnapPoint
disableDrag: boolean
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
}
export type DialogControlOpenOptions = {
@@ -52,11 +58,10 @@ export type DialogControlOpenOptions = {
export type DialogOuterProps = {
control: DialogControlProps
onClose?: () => void
nativeOptions?: {
sheet?: Omit<BottomSheetProps, 'children'>
}
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
webOptions?: {}
testID?: string
Portal?: PortalComponent
}
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
@@ -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 {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import * as Dialog from '#/components/Dialog'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
interface LikesDialogProps {
control: Dialog.DialogOuterProps['control']
@@ -25,7 +24,6 @@ export function LikesDialog(props: LikesDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<LikesDialogInner {...props} />
</Dialog.Outer>
)
+3 -3
View File
@@ -103,17 +103,17 @@ export function useLink({
linkRequiresWarning(href, displayText),
)
if (requiresWarning) {
if (isWeb) {
e.preventDefault()
}
if (requiresWarning) {
openModal({
name: 'link-warning',
text: displayText,
href: href,
})
} else {
e.preventDefault()
if (isExternal) {
openLink(href)
} else {
+11 -10
View File
@@ -4,7 +4,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import flattenReactChildren from 'react-keyed-flatten-children'
import {isNative} from 'platform/detection'
import {isNative} from '#/platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -82,19 +82,21 @@ export function Outer({
style?: StyleProp<ViewStyle>
}>) {
const context = React.useContext(Context)
const {_} = useLingui()
return (
<Dialog.Outer control={context.control}>
<Dialog.Outer
control={context.control}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
{/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}>
<Dialog.ScrollableInner label="Menu TODO">
<Dialog.ScrollableInner label={_(msg`Menu`)} style={[a.pt_sm]}>
<View style={[a.gap_lg]}>
{children}
{isNative && showCancel && <Cancel />}
<View style={[{height: a.pb_lg.paddingBottom}]} />
</View>
<View style={{height: a.gap_lg.gap}} />
</Dialog.ScrollableInner>
</Context.Provider>
</Dialog.Outer>
@@ -116,15 +118,14 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
{...rest}
accessibilityHint=""
accessibilityLabel={label}
onPress={e => {
onPress(e)
onFocus={onFocus}
onBlur={onBlur}
onPress={async e => {
await onPress(e)
if (!e.defaultPrevented) {
control?.close()
}
}}
onFocus={onFocus}
onBlur={onBlur}
onPressIn={e => {
onPressIn()
rest.onPressIn?.(e)
+3 -3
View File
@@ -5,12 +5,12 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns'
import {HITSLOP_10} from '#/lib/constants'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isNative} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {HITSLOP_10} from 'lib/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {useSession} from 'state/session'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
+2
View File
@@ -12,6 +12,8 @@ type ComponentMap = {
[id: string]: Component
}
export type PortalComponent = ({children}: {children?: React.ReactNode}) => null
export function createPortalGroup() {
const Context = React.createContext<ContextType>({
outlet: null,
+11 -7
View File
@@ -4,8 +4,9 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
import {Button, ButtonColor, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography'
export {
@@ -25,9 +26,11 @@ export function Outer({
children,
control,
testID,
Portal,
}: React.PropsWithChildren<{
control: Dialog.DialogControlProps
testID?: string
Portal?: PortalComponent
}>) {
const {gtMobile} = useBreakpoints()
const titleId = React.useId()
@@ -39,10 +42,9 @@ export function Outer({
)
return (
<Dialog.Outer control={control} testID={testID}>
<Dialog.Outer control={control} testID={testID} Portal={Portal}>
<Dialog.Handle />
<Context.Provider value={context}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId}
@@ -141,7 +143,7 @@ export function Action({
* Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method.
*/
onPress: ButtonProps['onPress']
onPress: (e: GestureResponderEvent) => void
color?: ButtonColor
/**
* Optional i18n string. If undefined, it will default to "Confirm".
@@ -181,6 +183,7 @@ export function Basic({
onConfirm,
confirmButtonColor,
showCancel = true,
Portal,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
title: string
@@ -194,12 +197,13 @@ export function Basic({
* Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method.
*/
onConfirm: ButtonProps['onPress']
onConfirm: (e: GestureResponderEvent) => void
confirmButtonColor?: ButtonColor
showCancel?: boolean
Portal?: PortalComponent
}>) {
return (
<Outer control={control} testID="confirmModal">
<Outer control={control} testID="confirmModal" Portal={Portal}>
<TitleText>{title}</TitleText>
<DescriptionText>{description}</DescriptionText>
<Actions>
@@ -4,7 +4,6 @@ import {AppBskyLabelerDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, useButtonContext} from '#/components/Button'
@@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {ReportOption} from '#/lib/moderation/useReportOptions'
import {isAndroid} from '#/platform/detection'
import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast'
@@ -225,6 +226,8 @@ export function SubmitView({
{submitting && <ButtonIcon icon={Loader} />}
</Button>
</View>
{/* Maybe fix this later -h */}
{isAndroid ? <View style={{height: 300}} /> : null}
</View>
)
}
+2 -7
View File
@@ -1,5 +1,6 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -8,12 +9,10 @@ import {useMyLabelersQuery} from '#/state/queries/preferences'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {AppBskyLabelerDefs} from '@atproto/api'
import {BottomSheetScrollViewMethods} from '@discord/bottom-sheet/src'
import {atoms as a} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
import {useOnKeyboardDidShow} from '#/components/hooks/useOnKeyboard'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {SelectLabelerView} from './SelectLabelerView'
@@ -25,7 +24,6 @@ export function ReportDialog(props: ReportDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<ReportDialogInner {...props} />
</Dialog.Outer>
)
@@ -40,10 +38,7 @@ function ReportDialogInner(props: ReportDialogProps) {
} = useMyLabelersQuery()
const isLoading = useDelayedLoading(500, isLabelerLoading)
const ref = React.useRef<BottomSheetScrollViewMethods>(null)
useOnKeyboardDidShow(() => {
ref.current?.scrollToEnd({animated: true})
})
const ref = React.useRef<ScrollView>(null)
return (
<Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}>
@@ -149,7 +149,6 @@ export function QrCodeDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Create a QR code for a starter pack`)}>
<View style={[a.flex_1, a.align_center, a.gap_5xl]}>
+8 -8
View File
@@ -6,14 +6,14 @@ import {AppBskyGraphDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {saveImageToMediaLibrary} from '#/lib/media/manip'
import {shareUrl} from '#/lib/sharing'
import {logEvent} from '#/lib/statsig/statsig'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {saveImageToMediaLibrary} from 'lib/media/manip'
import {shareUrl} from 'lib/sharing'
import {logEvent} from 'lib/statsig/statsig'
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
import {isNative, isWeb} from 'platform/detection'
import * as Toast from 'view/com/util/Toast'
import {isNative, isWeb} from '#/platform/detection'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {DialogControlProps} from '#/components/Dialog'
@@ -32,6 +32,7 @@ interface Props {
export function ShareDialog(props: Props) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<ShareDialogInner {...props} />
</Dialog.Outer>
)
@@ -84,7 +85,6 @@ function ShareDialogInner({
return (
<>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Share link dialog`)}>
{!imageLoaded || !link ? (
<View style={[a.p_xl, a.align_center]}>
@@ -3,13 +3,13 @@ import type {ListRenderItemInfo} from 'react-native'
import {View} from 'react-native'
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {isWeb} from '#/platform/detection'
import {useSession} from '#/state/session'
import {ListMethods} from '#/view/com/util/List'
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
@@ -45,7 +45,7 @@ export function WizardEditListDialog({
const {currentAccount} = useSession()
const initialNumToRender = useInitialNumToRender()
const listRef = useRef<BottomSheetFlatListMethods>(null)
const listRef = useRef<ListMethods>(null)
const getData = () => {
if (state.currentStep === 'Feeds') return state.feeds
@@ -76,10 +76,7 @@ export function WizardEditListDialog({
)
return (
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['95%']}}}>
<Dialog.Outer control={control} testID="newChatDialog">
<Dialog.Handle />
<Dialog.InnerFlatList
ref={listRef}
@@ -89,6 +86,7 @@ export function WizardEditListDialog({
ListHeaderComponent={
<View
style={[
native(a.pt_4xl),
a.flex_row,
a.justify_between,
a.border_b,
@@ -103,13 +101,7 @@ export function WizardEditListDialog({
height: 48,
},
]
: [
a.pb_sm,
a.align_end,
{
height: 68,
},
],
: [a.pb_sm, a.align_end],
]}>
<View style={{width: 60}} />
<Text style={[a.font_bold, a.text_xl]}>
@@ -143,8 +135,6 @@ export function WizardEditListDialog({
paddingHorizontal: 0,
marginTop: 0,
paddingTop: 0,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
}),
]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
-1
View File
@@ -85,7 +85,6 @@ export function TagMenu({
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
{isPreferencesLoading ? (
<View style={[a.w_full, a.align_center]}>
+19 -12
View File
@@ -53,11 +53,14 @@ export function childIsString(
)
}
export function renderChildrenWithEmoji(children: StringChild) {
export function renderChildrenWithEmoji(
children: StringChild,
props: Omit<TextProps, 'children'> = {},
) {
const normalized = Array.isArray(children) ? children : [children]
return (
<UITextView>
<UITextView {...props}>
{normalized.map(child => {
if (typeof child !== 'string') return child
@@ -68,10 +71,12 @@ export function renderChildrenWithEmoji(children: StringChild) {
}
return child.split(EMOJI).map((stringPart, index) => (
<UITextView key={index}>
<UITextView key={index} {...props}>
{stringPart}
{emojis[index] ? (
<UITextView style={{color: 'black', fontFamily: 'System'}}>
<UITextView
{...props}
style={[props?.style, {color: 'black', fontFamily: 'System'}]}>
{emojis[index]}
</UITextView>
) : null}
@@ -163,15 +168,17 @@ export function Text({
}
}
const shared = {
uiTextView: true,
selectable,
style: s,
dataSet: Object.assign({tooltip: title}, dataSet || {}),
...rest,
}
return (
<UITextView
selectable={selectable}
uiTextView
style={s}
{...rest}
// @ts-ignore
dataSet={Object.assign({tooltip: title}, dataSet || {})}>
{isIOS && emoji ? renderChildrenWithEmoji(children) : children}
<UITextView {...shared}>
{isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children}
</UITextView>
)
}
@@ -31,7 +31,6 @@ export function BirthDateSettingsDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`My Birthday`)}>
<View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_2xl, a.font_bold]}>
-1
View File
@@ -50,7 +50,6 @@ export function EmbedConsentDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`External Media`)}
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
-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,
} from 'react'
import {TextInput, View} from 'react-native'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {useWindowDimensions} from 'react-native'
import {Image} from 'expo-image'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {isWeb} from '#/platform/detection'
import {
@@ -19,7 +21,8 @@ import {
} from '#/state/queries/tenor'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {ListMethods} from '#/view/com/util/List'
import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
@@ -27,16 +30,18 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arr
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Button, ButtonIcon, ButtonText} from '../Button'
import {ListFooter, ListMaybePlaceholder} from '../Lists'
import {GifPreview} from './GifSelect.shared'
import {PortalComponent} from '../Portal'
export function GifSelectDialog({
controlRef,
onClose,
onSelectGif: onSelectGifProp,
Portal,
}: {
controlRef: React.RefObject<{open: () => void}>
onClose: () => void
onSelectGif: (gif: Gif) => void
Portal?: PortalComponent
}) {
const control = Dialog.useDialogControl()
@@ -59,8 +64,13 @@ export function GifSelectDialog({
return (
<Dialog.Outer
control={control}
nativeOptions={{sheet: {snapPoints: ['100%']}}}
onClose={onClose}>
onClose={onClose}
Portal={Portal}
nativeOptions={{
bottomInset: 0,
// use system corner radius on iOS
...ios({cornerRadius: undefined}),
}}>
<Dialog.Handle />
<ErrorBoundary renderError={renderErrorBoundary}>
<GifList control={control} onSelectGif={onSelectGif} />
@@ -80,9 +90,10 @@ function GifList({
const t = useTheme()
const {gtMobile} = useBreakpoints()
const textInputRef = useRef<TextInput>(null)
const listRef = useRef<BottomSheetFlatListMethods>(null)
const listRef = useRef<ListMethods>(null)
const [undeferredSearch, setSearch] = useState('')
const search = useThrottledValue(undeferredSearch, 500)
const {height} = useWindowDimensions()
const isSearching = search.length > 0
@@ -95,7 +106,7 @@ function GifList({
isFetchingNextPage,
hasNextPage,
error,
isLoading,
isPending,
isError,
refetch,
} = isSearching ? searchQuery : trendingQuery
@@ -132,6 +143,7 @@ function GifList({
return (
<View
style={[
native(a.pt_4xl),
a.relative,
a.mb_lg,
a.flex_row,
@@ -196,13 +208,14 @@ function GifList({
data={flattenedData}
renderItem={renderItem}
numColumns={gtMobile ? 3 : 2}
columnWrapperStyle={a.gap_sm}
columnWrapperStyle={[a.gap_sm]}
contentContainerStyle={[native([a.px_xl, {minHeight: height}])]}
ListHeaderComponent={
<>
{listHeader}
{!hasData && (
<ListMaybePlaceholder
isLoading={isLoading}
isLoading={isPending}
isError={isError}
onRetry={refetch}
onGoBack={onGoBack}
@@ -273,3 +286,47 @@ function DialogError({details}: {details?: string}) {
</Dialog.ScrollableInner>
)
}
export function GifPreview({
gif,
onSelectGif,
}: {
gif: Gif
onSelectGif: (gif: Gif) => void
}) {
const {gtTablet} = useBreakpoints()
const {_} = useLingui()
const t = useTheme()
const onPress = useCallback(() => {
logEvent('composer:gif:select', {})
onSelectGif(gif)
}, [onSelectGif, gif])
return (
<Button
label={_(msg`Select GIF "${gif.title}"`)}
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
onPress={onPress}>
{({pressed}) => (
<Image
style={[
a.flex_1,
a.mb_sm,
a.rounded_sm,
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
t.atoms.bg_contrast_25,
]}
source={{
uri: gif.media_formats.tinygif.url,
}}
contentFit="cover"
accessibilityLabel={gif.title}
accessibilityHint=""
cachePolicy="none"
accessibilityIgnoresInvertColors
/>
)}
</Button>
)
}
+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 {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import {createPortalGroup} from '#/components/Portal'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
const ONE_DAY = 24 * 60 * 60 * 1000
const Portal = createPortalGroup()
export function MutedWordsDialog() {
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
return (
@@ -105,307 +108,349 @@ function MutedWordsInner() {
}, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
return (
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
<View>
<Text
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
<Trans>Add muted words and tags</Trans>
</Text>
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Posts can be muted based on their text, their tags, or both. We
recommend avoiding common words that appear in many posts, since it
can result in no posts being shown.
</Trans>
</Text>
<View style={[a.pb_sm]}>
<Dialog.Input
autoCorrect={false}
autoCapitalize="none"
autoComplete="off"
label={_(msg`Enter a word or tag`)}
placeholder={_(msg`Enter a word or tag`)}
value={field}
onChangeText={value => {
if (error) {
setError('')
}
setField(value)
}}
onSubmitEditing={submit}
/>
</View>
<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]}>
<Portal.Provider>
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
<View>
<Text
style={[
a.text_md,
a.font_bold,
a.pb_md,
a.pb_sm,
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>
{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>
<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>
) : 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>
<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
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>
{isNative && <View style={{height: 20}} />}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
<Dialog.Close />
</Dialog.ScrollableInner>
<Portal.Outlet />
</Portal.Provider>
)
}
@@ -437,6 +482,7 @@ function MutedWordRow({
onConfirm={remove}
confirmButtonCta={_(msg`Remove`)}
confirmButtonColor="negative"
Portal={Portal.Portal}
/>
<View
@@ -37,6 +37,7 @@ import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography'
export type PostInteractionSettingsFormProps = {
@@ -54,13 +55,15 @@ export type PostInteractionSettingsFormProps = {
export function PostInteractionSettingsControlledDialog({
control,
Portal,
...rest
}: PostInteractionSettingsFormProps & {
control: Dialog.DialogControlProps
Portal?: PortalComponent
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Outer control={control} Portal={Portal}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
@@ -231,7 +234,6 @@ export function PostInteractionSettingsForm({
}: PostInteractionSettingsFormProps) {
const t = useTheme()
const {_} = useLingui()
const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate')
const [quotesEnabled, setQuotesEnabled] = React.useState(
!(
@@ -437,7 +439,6 @@ export function PostInteractionSettingsForm({
<Button
label={_(msg`Save`)}
onPress={onSave}
onAccessibilityEscape={control.close}
color="primary"
size="large"
variant="solid"
-1
View File
@@ -43,7 +43,6 @@ export function SwitchAccountDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Switch Account`)}>
<View style={[a.gap_lg]}>
<Text style={[a.text_2xl, a.font_bold]}>
@@ -44,7 +44,6 @@ export function NeueTypography() {
return (
<Dialog.Outer control={control} onClose={onClose}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Introducing new font settings`)}>
<View style={[a.gap_xl]}>
<View style={[a.gap_md]}>
+3 -3
View File
@@ -136,7 +136,7 @@ let ConvoMenu = ({
<Menu.Outer>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={leaveConvoControl.open}>
onPress={() => leaveConvoControl.open()}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
@@ -195,7 +195,7 @@ let ConvoMenu = ({
</Menu.Item>
<Menu.Item
label={_(msg`Report conversation`)}
onPress={reportControl.open}>
onPress={() => reportControl.open()}>
<Menu.ItemText>
<Trans>Report conversation</Trans>
</Menu.ItemText>
@@ -206,7 +206,7 @@ let ConvoMenu = ({
<Menu.Group>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={leaveConvoControl.open}>
onPress={() => leaveConvoControl.open()}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
+5 -5
View File
@@ -7,11 +7,11 @@ import {useLingui} from '@lingui/react'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {isWeb} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
import {useOpenLink} from '#/state/preferences/in-app-browser'
import {isWeb} from 'platform/detection'
import {useConvoActive} from 'state/messages/convo'
import {useSession} from 'state/session'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {ReportDialog} from '#/components/dms/ReportDialog'
@@ -120,7 +120,7 @@ export let MessageMenu = ({
<Menu.Item
testID="messageDropdownDeleteBtn"
label={_(msg`Delete message for me`)}
onPress={deleteControl.open}>
onPress={() => deleteControl.open()}>
<Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
@@ -128,7 +128,7 @@ export let MessageMenu = ({
<Menu.Item
testID="messageDropdownReportBtn"
label={_(msg`Report message`)}
onPress={reportControl.open}>
onPress={() => reportControl.open()}>
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item>
+1 -6
View File
@@ -10,13 +10,11 @@ import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {ReportOption} from '#/lib/moderation/useReportOptions'
import {isAndroid} from '#/platform/detection'
import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {KeyboardControllerPadding} from '#/components/KeyboardControllerPadding'
import {Button, ButtonIcon, ButtonText} from '../Button'
import {Divider} from '../Divider'
import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '../icons/Chevron'
@@ -41,14 +39,11 @@ let ReportDialog = ({
}): React.ReactNode => {
const {_} = useLingui()
return (
<Dialog.Outer
control={control}
nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Report this message`)}>
<DialogInner params={params} />
<Dialog.Close />
<KeyboardControllerPadding />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
+3 -5
View File
@@ -2,9 +2,9 @@ import React, {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import {FAB} from '#/view/com/util/fab/FAB'
import * as Toast from '#/view/com/util/Toast'
import {useTheme} from '#/alf'
@@ -55,10 +55,8 @@ export function NewChat({
accessibilityHint=""
/>
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<Dialog.Outer control={control} testID="newChatDialog">
<Dialog.Handle />
<SearchablePeopleList
title={_(msg`Start a new chat`)}
onSelectChat={onCreateChat}
@@ -5,10 +5,8 @@ import React, {
useRef,
useState,
} from 'react'
import type {TextInput as TextInputType} from 'react-native'
import {View} from 'react-native'
import {TextInput, View} from 'react-native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -16,18 +14,17 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useListConvosQuery} from '#/state/queries/messages/list-converations'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session'
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
import {ListMethods} from '#/view/com/util/List'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button} from '#/components/Button'
import {Button, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {TextInput} from '#/components/dms/dialogs/TextInput'
import {canBeMessaged} from '#/components/dms/util'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
@@ -66,9 +63,9 @@ export function SearchablePeopleList({
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const control = Dialog.useDialogContext()
const listRef = useRef<BottomSheetFlatListMethods>(null)
const listRef = useRef<ListMethods>(null)
const {currentAccount} = useSession()
const inputRef = useRef<TextInputType>(null)
const inputRef = useRef<TextInput>(null)
const [searchText, setSearchText] = useState('')
@@ -101,15 +98,15 @@ export function SearchablePeopleList({
})
}
_items = _items.sort(a => {
_items = _items.sort(item => {
// @ts-ignore
return a.enabled ? -1 : 1
return item.enabled ? -1 : 1
})
}
} else {
const placeholders: Item[] = Array(10)
.fill(0)
.map((_, i) => ({
.map((__, i) => ({
type: 'placeholder',
key: i + '',
}))
@@ -155,9 +152,9 @@ export function SearchablePeopleList({
}
// only sort follows
followsItems = followsItems.sort(a => {
followsItems = followsItems.sort(item => {
// @ts-ignore
return a.enabled ? -1 : 1
return item.enabled ? -1 : 1
})
// then append
@@ -177,9 +174,9 @@ export function SearchablePeopleList({
}
}
_items = _items.sort(a => {
_items = _items.sort(item => {
// @ts-ignore
return a.enabled ? -1 : 1
return item.enabled ? -1 : 1
})
} else {
_items.push(...placeholders)
@@ -242,57 +239,46 @@ export function SearchablePeopleList({
<View
style={[
a.relative,
a.pt_md,
web(a.pt_lg),
native(a.pt_4xl),
a.pb_xs,
a.px_lg,
a.border_b,
t.atoms.border_contrast_low,
t.atoms.bg,
native([a.pt_lg]),
]}>
<View
style={[
a.relative,
native(a.align_center),
a.justify_center,
{height: 32},
]}>
<Button
label={_(msg`Close`)}
size="small"
shape="round"
variant="ghost"
color="secondary"
style={[
a.absolute,
a.z_20,
native({
left: -7,
}),
web({
right: -4,
}),
]}
onPress={() => control.close()}>
{isWeb ? (
<X size="md" fill={t.palette.contrast_500} />
) : (
<ChevronLeft size="md" fill={t.palette.contrast_500} />
)}
</Button>
<View style={[a.relative, native(a.align_center), a.justify_center]}>
<Text
style={[
a.z_10,
a.text_lg,
a.font_bold,
a.font_heavy,
a.leading_tight,
t.atoms.text_contrast_high,
]}>
{title}
</Text>
{isWeb ? (
<Button
label={_(msg`Close`)}
size="small"
shape="round"
variant={isWeb ? 'ghost' : 'solid'}
color="secondary"
style={[
a.absolute,
a.z_20,
web({right: -4}),
native({right: 0}),
native({height: 32, width: 32, borderRadius: 16}),
]}
onPress={() => control.close()}>
<ButtonIcon icon={X} size="md" />
</Button>
) : null}
</View>
<View style={[native([a.pt_sm]), web([a.pt_xs])]}>
<View style={[, web([a.pt_xs])]}>
<SearchInput
inputRef={inputRef}
value={searchText}
@@ -309,7 +295,6 @@ export function SearchablePeopleList({
t.atoms.border_contrast_low,
t.atoms.bg,
t.atoms.text_contrast_high,
t.palette.contrast_500,
_,
title,
searchText,
@@ -326,14 +311,7 @@ export function SearchablePeopleList({
keyExtractor={(item: Item) => item.key}
style={[
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
native({
height: '100%',
paddingHorizontal: 0,
marginTop: 0,
paddingTop: 0,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
}),
native({height: '100%'}),
]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
keyboardDismissMode="on-drag"
@@ -396,7 +374,8 @@ function ProfileCard({
<View style={[a.flex_1, a.gap_2xs]}>
<Text
style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]}
numberOfLines={1}>
numberOfLines={1}
emoji>
{displayName}
</Text>
<Text
@@ -474,7 +453,7 @@ function SearchInput({
value: string
onChangeText: (text: string) => void
onEscape: () => void
inputRef: React.RefObject<TextInputType>
inputRef: React.RefObject<TextInput>
}) {
const t = useTheme()
const {_} = useLingui()
@@ -2,9 +2,9 @@ import React, {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import * as Toast from '#/view/com/util/Toast'
import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from './SearchablePeopleList'
@@ -17,10 +17,8 @@ export function SendViaChatDialog({
onSelectChat: (chatId: string) => void
}) {
return (
<Dialog.Outer
control={control}
testID="sendViaChatChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<Dialog.Outer control={control} testID="sendViaChatChatDialog">
<Dialog.Handle />
<SendViaChatDialogInner control={control} onSelectChat={onSelectChat} />
</Dialog.Outer>
)
+1 -1
View File
@@ -2,8 +2,8 @@ import React from 'react'
import {Pressable, View, ViewStyle} from 'react-native'
import Animated, {LinearTransition} from 'react-native-reanimated'
import {HITSLOP_10} from '#/lib/constants'
import {isNative} from '#/platform/detection'
import {HITSLOP_10} from 'lib/constants'
import {
atoms as a,
flatten,
+36 -35
View File
@@ -32,7 +32,6 @@ export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<LabelsOnMeDialogInner {...props} />
</Dialog.Outer>
)
@@ -158,23 +157,25 @@ function Label({
<Divider />
<View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}>
<Text style={[t.atoms.text_contrast_medium]}>
{isSelfLabel ? (
{isSelfLabel ? (
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>This label was applied by you.</Trans>
) : (
<Trans>
Source:{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}>
{sourceName}
</InlineLinkText>
</Trans>
)}
</Text>
</Text>
) : (
<View style={{flexDirection: 'row'}}>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>Source: </Trans>{' '}
</Text>
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}>
{sourceName}
</InlineLinkText>
</View>
)}
</View>
</View>
)
@@ -236,24 +237,24 @@ function AppealForm({
return (
<>
<Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
<Trans>Appeal "{strings.name}" label</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
This appeal will be sent to{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}
style={[a.text_md, a.leading_snug]}>
{sourceName}
</InlineLinkText>
.
</Trans>
</Text>
<View style={{flexWrap: 'wrap', flexDirection: 'row'}}>
<Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
<Trans>Appeal "{strings.name}" label</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>This appeal will be sent to</Trans>{' '}
</Text>
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}
style={[a.text_md, a.leading_snug]}>
{sourceName}
</InlineLinkText>
<Text style={[a.text_md, a.leading_snug]}>.</Text>
</View>
<View style={[a.my_md]}>
<Dialog.Input
label={_(msg`Text input field`)}
@@ -141,23 +141,24 @@ function ModerationDetailsDialogInner({
{modcause?.type === 'label' && (
<View style={[a.pt_lg]}>
<Divider />
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
{modcause.source.type === 'user' ? (
{modcause.source.type === 'user' ? (
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
<Trans>This label was applied by the author.</Trans>
) : (
<Trans>
This label was applied by{' '}
<InlineLinkText
label={desc.source || _(msg`an unknown labeler`)}
to={makeProfileLink({did: modcause.label.src, handle: ''})}
onPress={() => control.close()}
style={a.text_md}>
{desc.source || _(msg`an unknown labeler`)}
</InlineLinkText>
.
</Trans>
)}
</Text>
</Text>
) : (
<>
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
<Trans>This label was applied by </Trans>
</Text>
<InlineLinkText
label={desc.source || _(msg`an unknown labeler`)}
to={makeProfileLink({did: modcause.label.src, handle: ''})}
onPress={() => control.close()}
style={a.text_md}>
{desc.source || _(msg`an unknown labeler`)}
</InlineLinkText>
</>
)}
</View>
)}
+15 -17
View File
@@ -1,5 +1,4 @@
import {
AppBskyEmbedDefs,
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecord,
@@ -7,7 +6,6 @@ import {
AppBskyEmbedVideo,
AppBskyFeedPostgate,
AtUri,
BlobRef,
BskyAgent,
ComAtprotoLabelDefs,
RichText,
@@ -46,14 +44,7 @@ interface PostOpts {
uri: string
cid: string
}
video?: {
blobRef: BlobRef
altText: string
captions: {lang: string; file: File}[]
aspectRatio?: AppBskyEmbedDefs.AspectRatio
}
extLink?: ExternalEmbedDraft
images?: ComposerImage[]
labels?: string[]
threadgate: ThreadgateAllowUISetting[]
postgate: AppBskyFeedPostgate.Record
@@ -230,13 +221,15 @@ async function resolveMedia(
| AppBskyEmbedVideo.Main
| undefined
> {
if (opts.images?.length) {
const state = opts.composerState
const media = state.embed.media
if (media?.type === 'images') {
logger.debug(`Uploading images`, {
count: opts.images.length,
count: media.images.length,
})
opts.onStateChange?.(`Uploading images...`)
const images: AppBskyEmbedImages.Image[] = await Promise.all(
opts.images.map(async (image, i) => {
media.images.map(async (image, i) => {
logger.debug(`Compressing image #${i}`)
const {path, width, height, mime} = await compressImage(image)
logger.debug(`Uploading image #${i}`)
@@ -253,9 +246,10 @@ async function resolveMedia(
images,
}
}
if (opts.video) {
if (media?.type === 'video' && media.video.status === 'done') {
const video = media.video
const captions = await Promise.all(
opts.video.captions
video.captions
.filter(caption => caption.lang !== '')
.map(async caption => {
const {data} = await agent.uploadBlob(caption.file, {
@@ -266,13 +260,17 @@ async function resolveMedia(
)
return {
$type: 'app.bsky.embed.video',
video: opts.video.blobRef,
alt: opts.video.altText || undefined,
video: video.pendingPublish.blobRef,
alt: video.altText || undefined,
captions: captions.length === 0 ? undefined : captions,
aspectRatio: opts.video.aspectRatio,
aspectRatio: {
width: video.asset.width,
height: video.asset.height,
},
}
}
if (opts.extLink) {
// TODO: Read this from composer state as well.
if (opts.extLink.embed) {
return undefined
}
+1 -1
View File
@@ -7,8 +7,8 @@ import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from './util'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
export async function uploadVideo({
video,
+1 -1
View File
@@ -7,8 +7,8 @@ import {nanoid} from 'nanoid/non-secure'
import {AbortError} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from './util'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
export async function uploadVideo({
video,
+24 -13
View File
@@ -32,6 +32,7 @@ import {
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo'
@@ -89,15 +90,18 @@ export function StepProfile() {
requestNotificationsPermission('StartOnboarding')
}, [gate, requestNotificationsPermission])
const sheetWrapper = useSheetWrapper()
const openPicker = React.useCallback(
async (opts?: ImagePickerOptions) => {
const response = await launchImageLibraryAsync({
exif: false,
mediaTypes: MediaTypeOptions.Images,
quality: 1,
...opts,
legacy: true,
})
const response = await sheetWrapper(
launchImageLibraryAsync({
exif: false,
mediaTypes: MediaTypeOptions.Images,
quality: 1,
...opts,
legacy: true,
}),
)
return (response.assets ?? [])
.slice(0, 1)
@@ -121,7 +125,7 @@ export function StepProfile() {
size: getDataUriSize(image.uri),
}))
},
[_, setError],
[_, setError, sheetWrapper],
)
const onContinue = React.useCallback(async () => {
@@ -168,9 +172,11 @@ export function StepProfile() {
setError('')
const items = await openPicker({
aspect: [1, 1],
})
const items = await sheetWrapper(
openPicker({
aspect: [1, 1],
}),
)
let image = items[0]
if (!image) return
@@ -196,7 +202,13 @@ export function StepProfile() {
image,
useCreatedAvatar: false,
}))
}, [requestPhotoAccessIfNeeded, setAvatar, openPicker, setError])
}, [
requestPhotoAccessIfNeeded,
setAvatar,
openPicker,
setError,
sheetWrapper,
])
const onSecondaryPress = React.useCallback(() => {
if (avatar.useCreatedAvatar) {
@@ -286,7 +298,6 @@ export function StepProfile() {
</View>
<Dialog.Outer control={creatorControl}>
<Dialog.Handle />
<Dialog.Inner
label="Avatar creator"
style={[
+22 -22
View File
@@ -15,35 +15,35 @@ import {useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {HITSLOP_20} from '#/lib/constants'
import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
import {makeProfileLink, makeStarterPackLink} from '#/lib/routes/links'
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {getAllListMembers} from '#/state/queries/list-members'
import {useResolvedStarterPackShortLink} from '#/state/queries/resolve-short-link'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useShortenLink} from '#/state/queries/shorten-link'
import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs'
import {useStarterPackQuery} from '#/state/queries/starter-packs'
import {useAgent, useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {batchedUpdates} from 'lib/batchedUpdates'
import {HITSLOP_20} from 'lib/constants'
import {isBlockedOrBlocking, isMuted} from 'lib/moderation/blocked-and-muted'
import {makeProfileLink, makeStarterPackLink} from 'lib/routes/links'
import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types'
import {logEvent} from 'lib/statsig/statsig'
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
import {isWeb} from 'platform/detection'
import {updateProfileShadow} from 'state/cache/profile-shadow'
import {useModerationOpts} from 'state/preferences/moderation-opts'
import {getAllListMembers} from 'state/queries/list-members'
import {useResolvedStarterPackShortLink} from 'state/queries/resolve-short-link'
import {useResolveDidQuery} from 'state/queries/resolve-uri'
import {useShortenLink} from 'state/queries/shorten-link'
import {useStarterPackQuery} from 'state/queries/starter-packs'
import {useAgent, useSession} from 'state/session'
import {useLoggedOutViewControls} from 'state/shell/logged-out'
import {useSetActiveStarterPack} from 'state/shell/starter-pack'
import {useSetActiveStarterPack} from '#/state/shell/starter-pack'
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader'
import * as Toast from '#/view/com/util/Toast'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
import {CenteredView} from 'view/com/util/Views'
import {CenteredView} from '#/view/com/util/Views'
import {bulkWriteFollows} from '#/screens/Onboarding/util'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -591,7 +591,7 @@ function OverflowMenu({
<Menu.Item
label={_(msg`Report starter pack`)}
onPress={reportDialogControl.open}>
onPress={() => reportDialogControl.open()}>
<Menu.ItemText>
<Trans>Report starter pack</Trans>
</Menu.ItemText>
+47 -40
View File
@@ -1,8 +1,9 @@
import React from 'react'
import {SharedValue, useSharedValue} from 'react-native-reanimated'
import {isWeb} from '#/platform/detection'
import {DialogControlRefProps} from '#/components/Dialog'
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
import {BottomSheet} from '../../../modules/bottom-sheet'
interface IDialogContext {
/**
@@ -16,25 +17,24 @@ interface IDialogContext {
* `useId`.
*/
openDialogs: React.MutableRefObject<Set<string>>
}
interface IDialogControlContext {
closeAllDialogs(): boolean
setDialogIsOpen(id: string, isOpen: boolean): void
/**
* The counterpart to `accessibilityViewIsModal` for Android. This property
* applies to the parent of all non-modal views, and prevents TalkBack from
* navigating within content beneath an open dialog.
*
* @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android
* The number of dialogs that are fully expanded. This is used to determine the backgground color of the status bar
* on iOS.
*/
importantForAccessibility: SharedValue<'auto' | 'no-hide-descendants'>
fullyExpandedCount: number
setFullyExpandedCount: React.Dispatch<React.SetStateAction<number>>
}
const DialogContext = React.createContext<IDialogContext>({} as IDialogContext)
const DialogControlContext = React.createContext<{
closeAllDialogs(): boolean
setDialogIsOpen(id: string, isOpen: boolean): void
}>({
closeAllDialogs: () => false,
setDialogIsOpen: () => {},
})
const DialogControlContext = React.createContext<IDialogControlContext>(
{} as IDialogControlContext,
)
export function useDialogStateContext() {
return React.useContext(DialogContext)
@@ -45,48 +45,55 @@ export function useDialogStateControlContext() {
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const [fullyExpandedCount, setFullyExpandedCount] = React.useState(0)
const activeDialogs = React.useRef<
Map<string, React.MutableRefObject<DialogControlRefProps>>
>(new Map())
const openDialogs = React.useRef<Set<string>>(new Set())
const importantForAccessibility = useSharedValue<
'auto' | 'no-hide-descendants'
>('auto')
const closeAllDialogs = React.useCallback(() => {
openDialogs.current.forEach(id => {
const dialog = activeDialogs.current.get(id)
if (dialog) dialog.current.close()
})
return openDialogs.current.size > 0
if (isWeb) {
openDialogs.current.forEach(id => {
const dialog = activeDialogs.current.get(id)
if (dialog) dialog.current.close()
})
return openDialogs.current.size > 0
} else {
BottomSheet.dismissAll()
return false
}
}, [])
const setDialogIsOpen = React.useCallback(
(id: string, isOpen: boolean) => {
if (isOpen) {
openDialogs.current.add(id)
importantForAccessibility.value = 'no-hide-descendants'
} else {
openDialogs.current.delete(id)
if (openDialogs.current.size < 1) {
importantForAccessibility.value = 'auto'
}
}
},
[importantForAccessibility],
)
const setDialogIsOpen = React.useCallback((id: string, isOpen: boolean) => {
if (isOpen) {
openDialogs.current.add(id)
} else {
openDialogs.current.delete(id)
}
}, [])
const context = React.useMemo<IDialogContext>(
() => ({
activeDialogs,
openDialogs,
importantForAccessibility,
}),
[importantForAccessibility, activeDialogs, openDialogs],
[activeDialogs, openDialogs],
)
const controls = React.useMemo(
() => ({closeAllDialogs, setDialogIsOpen}),
[closeAllDialogs, setDialogIsOpen],
() => ({
closeAllDialogs,
setDialogIsOpen,
fullyExpandedCount,
setFullyExpandedCount,
}),
[
closeAllDialogs,
setDialogIsOpen,
fullyExpandedCount,
setFullyExpandedCount,
],
)
return (
+6 -6
View File
@@ -2,14 +2,14 @@ import React from 'react'
import {Linking} from 'react-native'
import * as WebBrowser from 'expo-web-browser'
import {isNative} from '#/platform/detection'
import * as persisted from '#/state/persisted'
import {usePalette} from 'lib/hooks/usePalette'
import {usePalette} from '#/lib/hooks/usePalette'
import {
createBskyAppAbsoluteUrl,
isBskyRSSUrl,
isRelativeUrl,
} from 'lib/strings/url-helpers'
} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import * as persisted from '#/state/persisted'
import {useModalControls} from '../modals'
type StateContext = persisted.Schema['useInAppBrowser']
@@ -62,7 +62,7 @@ export function useOpenLink() {
const pal = usePalette('default')
const openLink = React.useCallback(
(url: string, override?: boolean) => {
async (url: string, override?: boolean) => {
if (isBskyRSSUrl(url) && isRelativeUrl(url)) {
url = createBskyAppAbsoluteUrl(url)
}
@@ -75,7 +75,7 @@ export function useOpenLink() {
})
return
} else if (override ?? enabled) {
WebBrowser.openBrowserAsync(url, {
await WebBrowser.openBrowserAsync(url, {
presentationStyle:
WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN,
toolbarColor: pal.colors.backgroundLight,
-15
View File
@@ -6,21 +6,6 @@
* may need to touch all three. Ask Eric if you aren't sure.
*/
@font-face {
font-family: 'InterVariable';
src: url(/assets/fonts/inter/InterVariable.ttf) format('truetype');
font-weight: 300 1000;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'InterVariableItalic';
src: url(/assets/fonts/inter/InterVariable-Italic.ttf) format('truetype');
font-weight: 300 1000;
font-style: italic;
font-display: swap;
}
/**
* BEGIN STYLES
*
+1 -5
View File
@@ -66,12 +66,8 @@ export function ServerInputDialog({
])
return (
<Dialog.Outer
control={control}
nativeOptions={{sheet: {snapPoints: ['100%']}}}
onClose={onClose}>
<Dialog.Outer control={control} onClose={onClose}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title">
+300 -268
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 {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {createPortalGroup} from '#/components/Portal'
import * as Prompt from '#/components/Prompt'
import {Text as NewText} from '#/components/Typography'
import {composerReducer, createComposerState} from './state/composer'
import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
const Portal = createPortalGroup()
const MAX_IMAGES = 4
type CancelRef = {
@@ -184,13 +187,10 @@ export const ComposePost = ({
initQuote,
)
const [videoAltText, setVideoAltText] = useState('')
const [captions, setCaptions] = useState<{lang: string; file: File}[]>([])
// TODO: Move more state here.
const [composerState, dispatch] = useReducer(
composerReducer,
{initImageUris},
{initImageUris, initQuoteUri: initQuote?.uri},
createComposerState,
)
@@ -337,6 +337,7 @@ export const ComposePost = ({
const onNewLink = useCallback(
(uri: string) => {
dispatch({type: 'embed_add_uri', uri})
if (extLink != null) return
setExtLink({uri, isLoading: true})
},
@@ -421,10 +422,9 @@ export const ComposePost = ({
try {
postUri = (
await apilib.post(agent, {
composerState, // TODO: not used yet.
composerState, // TODO: move more state here.
rawText: richtext.text,
replyTo: replyTo?.uri,
images,
quote,
extLink,
labels,
@@ -432,18 +432,6 @@ export const ComposePost = ({
postgate,
onStateChange: setProcessingState,
langs: toPostLanguages(langPrefs.postLanguage),
video:
videoState.status === 'done'
? {
blobRef: videoState.pendingPublish.blobRef,
altText: videoAltText,
captions: captions,
aspectRatio: {
width: videoState.asset.width,
height: videoState.asset.height,
},
}
: undefined,
})
).uri
try {
@@ -521,7 +509,6 @@ export const ComposePost = ({
[
_,
agent,
captions,
composerState,
extLink,
images,
@@ -540,9 +527,7 @@ export const ComposePost = ({
setExtLink,
setLangPrefs,
threadgateAllowUISettings,
videoAltText,
videoState.asset,
videoState.pendingPublish,
videoState.status,
],
)
@@ -582,6 +567,7 @@ export const ComposePost = ({
const onSelectGif = useCallback(
(gif: Gif) => {
dispatch({type: 'embed_add_gif', gif})
setExtLink({
uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`,
isLoading: true,
@@ -600,6 +586,7 @@ export const ComposePost = ({
const handleChangeGifAltText = useCallback(
(altText: string) => {
dispatch({type: 'embed_update_gif', alt: altText})
setExtLink(ext =>
ext && ext.meta
? {
@@ -629,268 +616,313 @@ export const ComposePost = ({
const keyboardVerticalOffset = useKeyboardVerticalOffset()
return (
<KeyboardAvoidingView
testID="composePostView"
behavior={isIOS ? 'padding' : 'height'}
keyboardVerticalOffset={keyboardVerticalOffset}
style={a.flex_1}>
<View style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal>
<Animated.View
style={topBarAnimatedStyle}
layout={native(LinearTransition)}>
<View style={styles.topbarInner}>
<Button
label={_(msg`Cancel`)}
variant="ghost"
color="primary"
shape="default"
size="small"
style={[
a.rounded_full,
a.py_sm,
{paddingLeft: 7, paddingRight: 7},
]}
onPress={onPressCancel}
accessibilityHint={_(
msg`Closes post composer and discards post draft`,
)}>
<ButtonText style={[a.text_md]}>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
{isProcessing ? (
<>
<Text style={pal.textLight}>{processingState}</Text>
<View style={styles.postBtn}>
<ActivityIndicator />
</View>
</>
) : (
<View style={[styles.postBtnWrapper]}>
<LabelsBtn
labels={labels}
onChange={setLabels}
hasMedia={hasMedia}
/>
{canPost ? (
<Button
testID="composerPublishBtn"
label={
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
}
variant="solid"
color="primary"
shape="default"
size="small"
style={[a.rounded_full, a.py_sm]}
onPress={() => onPressPublish()}
disabled={videoState.status !== 'idle' && publishOnUpload}>
<ButtonText style={[a.text_md]}>
{replyTo ? (
<Trans context="action">Reply</Trans>
) : (
<Trans context="action">Post</Trans>
)}
</ButtonText>
</Button>
) : (
<View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>
<Trans context="action">Post</Trans>
</Text>
<Portal.Provider>
<KeyboardAvoidingView
testID="composePostView"
behavior={isIOS ? 'padding' : 'height'}
keyboardVerticalOffset={keyboardVerticalOffset}
style={a.flex_1}>
<View
style={[a.flex_1, viewStyles]}
aria-modal
accessibilityViewIsModal>
<Animated.View
style={topBarAnimatedStyle}
layout={native(LinearTransition)}>
<View style={styles.topbarInner}>
<Button
label={_(msg`Cancel`)}
variant="ghost"
color="primary"
shape="default"
size="small"
style={[
a.rounded_full,
a.py_sm,
{paddingLeft: 7, paddingRight: 7},
]}
onPress={onPressCancel}
accessibilityHint={_(
msg`Closes post composer and discards post draft`,
)}>
<ButtonText style={[a.text_md]}>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
{isProcessing ? (
<>
<Text style={pal.textLight}>{processingState}</Text>
<View style={styles.postBtn}>
<ActivityIndicator />
</View>
)}
</>
) : (
<View style={[styles.postBtnWrapper]}>
<LabelsBtn
labels={labels}
onChange={setLabels}
hasMedia={hasMedia}
/>
{canPost ? (
<Button
testID="composerPublishBtn"
label={
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
}
variant="solid"
color="primary"
shape="default"
size="small"
style={[a.rounded_full, a.py_sm]}
onPress={() => onPressPublish()}
disabled={
videoState.status !== 'idle' && publishOnUpload
}>
<ButtonText style={[a.text_md]}>
{replyTo ? (
<Trans context="action">Reply</Trans>
) : (
<Trans context="action">Post</Trans>
)}
</ButtonText>
</Button>
) : (
<View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>
<Trans context="action">Post</Trans>
</Text>
</View>
)}
</View>
)}
</View>
{isAltTextRequiredAndMissing && (
<View style={[styles.reminderLine, pal.viewLight]}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
/>
</View>
<Text style={[pal.text, a.flex_1]}>
<Trans>One or more images is missing alt text.</Trans>
</Text>
</View>
)}
</View>
<ErrorBanner
error={error}
videoState={videoState}
clearError={() => setError('')}
clearVideo={clearVideo}
/>
</Animated.View>
<Animated.ScrollView
layout={native(LinearTransition)}
onScroll={scrollHandler}
style={styles.scrollView}
keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
{isAltTextRequiredAndMissing && (
<View style={[styles.reminderLine, pal.viewLight]}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
<View
style={[
styles.textInputLayout,
isNative && styles.textInputLayoutMobile,
]}>
<UserAvatar
avatar={currentProfile?.avatar}
size={50}
type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
/>
<TextInput
ref={textInput}
richtext={richtext}
placeholder={selectTextInputPlaceholder}
autoFocus
setRichText={setRichText}
onPhotoPasted={onPhotoPasted}
onPressPublish={() => onPressPublish()}
onNewLink={onNewLink}
onError={setError}
accessible={true}
accessibilityLabel={_(msg`Write post`)}
accessibilityHint={_(
msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
)}
/>
</View>
<Gallery
images={images}
dispatch={dispatch}
Portal={Portal.Portal}
/>
{images.length === 0 && extLink && (
<View style={a.relative}>
<ExternalEmbed
link={extLink}
gif={extGif}
onRemove={() => {
if (extGif) {
dispatch({type: 'embed_remove_gif'})
} else {
dispatch({type: 'embed_remove_link'})
}
setExtLink(undefined)
setExtGif(undefined)
}}
/>
<GifAltText
link={extLink}
gif={extGif}
onSubmit={handleChangeGifAltText}
Portal={Portal.Portal}
/>
</View>
<Text style={[pal.text, a.flex_1]}>
<Trans>One or more images is missing alt text.</Trans>
</Text>
)}
<LayoutAnimationConfig skipExiting>
{hasVideo && (
<Animated.View
style={[a.w_full, a.mt_lg]}
entering={native(ZoomIn)}
exiting={native(ZoomOut)}>
{videoState.asset &&
(videoState.status === 'compressing' ? (
<VideoTranscodeProgress
asset={videoState.asset}
progress={videoState.progress}
clear={clearVideo}
/>
) : videoState.video ? (
<VideoPreview
asset={videoState.asset}
video={videoState.video}
setDimensions={updateVideoDimensions}
clear={clearVideo}
/>
) : null)}
<SubtitleDialogBtn
defaultAltText={videoState.altText}
saveAltText={altText =>
dispatch({
type: 'embed_update_video',
videoAction: {
type: 'update_alt_text',
altText,
signal: videoState.abortController.signal,
},
})
}
captions={videoState.captions}
setCaptions={updater => {
dispatch({
type: 'embed_update_video',
videoAction: {
type: 'update_captions',
updater,
signal: videoState.abortController.signal,
},
})
}}
Portal={Portal.Portal}
/>
</Animated.View>
)}
</LayoutAnimationConfig>
<View style={!hasVideo ? [a.mt_md] : []}>
{quote ? (
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} />
</View>
{quote.uri !== initQuote?.uri && (
<QuoteX
onRemove={() => {
dispatch({type: 'embed_remove_quote'})
setQuote(undefined)
}}
/>
)}
</View>
) : null}
</View>
)}
<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}
</Animated.ScrollView>
<SuggestedLanguage text={richtext.text} />
{replyTo ? null : (
<ThreadgateBtn
postgate={postgate}
onChangePostgate={setPostgate}
threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={
onChangeThreadgateAllowUISettings
}
style={bottomBarAnimatedStyle}
Portal={Portal.Portal}
/>
)}
<View
style={[
styles.textInputLayout,
isNative && styles.textInputLayoutMobile,
t.atoms.bg,
t.atoms.border_contrast_medium,
styles.bottomBar,
]}>
<UserAvatar
avatar={currentProfile?.avatar}
size={50}
type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
/>
<TextInput
ref={textInput}
richtext={richtext}
placeholder={selectTextInputPlaceholder}
autoFocus
setRichText={setRichText}
onPhotoPasted={onPhotoPasted}
onPressPublish={() => onPressPublish()}
onNewLink={onNewLink}
onError={setError}
accessible={true}
accessibilityLabel={_(msg`Write post`)}
accessibilityHint={_(
msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
)}
/>
</View>
<Gallery images={images} dispatch={dispatch} />
{images.length === 0 && extLink && (
<View style={a.relative}>
<ExternalEmbed
link={extLink}
gif={extGif}
onRemove={() => {
setExtLink(undefined)
setExtGif(undefined)
}}
/>
<GifAltText
link={extLink}
gif={extGif}
onSubmit={handleChangeGifAltText}
/>
</View>
)}
<LayoutAnimationConfig skipExiting>
{hasVideo && (
<Animated.View
style={[a.w_full, a.mt_lg]}
entering={native(ZoomIn)}
exiting={native(ZoomOut)}>
{videoState.asset &&
(videoState.status === 'compressing' ? (
<VideoTranscodeProgress
asset={videoState.asset}
progress={videoState.progress}
clear={clearVideo}
/>
) : videoState.video ? (
<VideoPreview
asset={videoState.asset}
video={videoState.video}
setDimensions={updateVideoDimensions}
clear={clearVideo}
/>
) : null)}
<SubtitleDialogBtn
defaultAltText={videoAltText}
saveAltText={setVideoAltText}
captions={captions}
setCaptions={setCaptions}
{videoState.status !== 'idle' && videoState.status !== 'done' ? (
<VideoUploadToolbar state={videoState} />
) : (
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn
size={images.length}
disabled={!canSelectImages}
onAdd={onImageAdd}
/>
</Animated.View>
<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>
)}
</LayoutAnimationConfig>
<View style={!hasVideo ? [a.mt_md] : []}>
{quote ? (
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} />
</View>
{quote.uri !== initQuote?.uri && (
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View>
) : null}
<View style={a.flex_1} />
<SelectLangBtn />
<CharProgress count={graphemeLength} />
</View>
</Animated.ScrollView>
<SuggestedLanguage text={richtext.text} />
{replyTo ? null : (
<ThreadgateBtn
postgate={postgate}
onChangePostgate={setPostgate}
threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={
onChangeThreadgateAllowUISettings
}
style={bottomBarAnimatedStyle}
/>
)}
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_medium,
styles.bottomBar,
]}>
{videoState.status !== 'idle' && videoState.status !== 'done' ? (
<VideoUploadToolbar state={videoState} />
) : (
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn
size={images.length}
disabled={!canSelectImages}
onAdd={onImageAdd}
/>
<SelectVideoBtn
onSelectVideo={selectVideo}
disabled={!canSelectImages || images?.length > 0}
setError={setError}
/>
<OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
<SelectGifBtn
onClose={focusTextInput}
onSelectGif={onSelectGif}
disabled={hasMedia}
/>
{!isMobile ? (
<Button
onPress={onEmojiButtonPress}
style={a.p_sm}
label={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Open emoji picker`)}
variant="ghost"
shape="round"
color="primary">
<EmojiSmile size="lg" />
</Button>
) : null}
</ToolbarWrapper>
)}
<View style={a.flex_1} />
<SelectLangBtn />
<CharProgress count={graphemeLength} />
</View>
</View>
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard draft?`)}
description={_(msg`Are you sure you'd like to discard this draft?`)}
onConfirm={onClose}
confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative"
/>
</KeyboardAvoidingView>
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard draft?`)}
description={_(msg`Are you sure you'd like to discard this draft?`)}
onConfirm={onClose}
confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative"
Portal={Portal.Portal}
/>
</KeyboardAvoidingView>
<Portal.Outlet />
</Portal.Provider>
)
}
+6 -3
View File
@@ -20,6 +20,7 @@ import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography'
import {GifEmbed} from '../util/post-embeds/GifEmbed'
import {AltTextReminder} from './photos/Gallery'
@@ -28,10 +29,12 @@ export function GifAltText({
link: linkProp,
gif,
onSubmit,
Portal,
}: {
link: ExternalEmbedDraft
gif?: Gif
onSubmit: (alt: string) => void
Portal: PortalComponent
}) {
const control = Dialog.useDialogControl()
const {_} = useLingui()
@@ -96,9 +99,7 @@ export function GifAltText({
<AltTextReminder />
<Dialog.Outer
control={control}
nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
<Dialog.Outer control={control} Portal={Portal}>
<Dialog.Handle />
<AltTextInner
onSubmit={onPressSubmit}
@@ -185,6 +186,8 @@ function AltTextInner({
</View>
</View>
<Dialog.Close />
{/* Maybe fix this later -h */}
{isAndroid ? <View style={{height: 300}} /> : null}
</Dialog.ScrollableInner>
)
}
@@ -20,6 +20,7 @@ import {EditImageDialogProps} from './EditImageDialog'
export const EditImageDialog = (props: EditImageDialogProps) => {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<EditImageInner key={props.image.source.id} {...props} />
</Dialog.Outer>
)
+12 -1
View File
@@ -21,6 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery'
import {Text} from '#/view/com/util/text/Text'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {PortalComponent} from '#/components/Portal'
import {ComposerAction} from '../state/composer'
import {EditImageDialog} from './EditImageDialog'
import {ImageAltTextDialog} from './ImageAltTextDialog'
@@ -30,6 +31,7 @@ const IMAGE_GAP = 8
interface GalleryProps {
images: ComposerImage[]
dispatch: (action: ComposerAction) => void
Portal: PortalComponent
}
export let Gallery = (props: GalleryProps): React.ReactNode => {
@@ -57,7 +59,12 @@ interface GalleryInnerProps extends GalleryProps {
containerInfo: Dimensions
}
const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
const GalleryInner = ({
images,
containerInfo,
dispatch,
Portal,
}: GalleryInnerProps) => {
const {isMobile} = useWebMediaQueries()
const {altTextControlStyle, imageControlsStyle, imageStyle} =
@@ -111,6 +118,7 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
onRemove={() => {
dispatch({type: 'embed_remove_image', image})
}}
Portal={Portal}
/>
)
})}
@@ -127,6 +135,7 @@ type GalleryItemProps = {
imageStyle?: ViewStyle
onChange: (next: ComposerImage) => void
onRemove: () => void
Portal: PortalComponent
}
const GalleryItem = ({
@@ -136,6 +145,7 @@ const GalleryItem = ({
imageStyle,
onChange,
onRemove,
Portal,
}: GalleryItemProps): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
@@ -230,6 +240,7 @@ const GalleryItem = ({
control={altTextControl}
image={image}
onChange={onChange}
Portal={Portal}
/>
<EditImageDialog
@@ -5,25 +5,26 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {MAX_ALT_TEXT} from '#/lib/constants'
import {isWeb} from '#/platform/detection'
import {isAndroid, isWeb} from '#/platform/detection'
import {ComposerImage} from '#/state/gallery'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography'
type Props = {
control: Dialog.DialogOuterProps['control']
image: ComposerImage
onChange: (next: ComposerImage) => void
Portal: PortalComponent
}
export const ImageAltTextDialog = (props: Props): React.ReactNode => {
return (
<Dialog.Outer control={props.control}>
<Dialog.Outer control={props.control} Portal={props.Portal}>
<Dialog.Handle />
<ImageAltTextInner {...props} />
</Dialog.Outer>
)
@@ -116,6 +117,8 @@ const ImageAltTextInner = ({
</ButtonText>
</Button>
</View>
{/* Maybe fix this later -h */}
{isAndroid ? <View style={{height: 300}} /> : null}
</Dialog.ScrollableInner>
)
}
@@ -9,14 +9,16 @@ import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {GifSelectDialog} from '#/components/dialogs/GifSelect'
import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif'
import {PortalComponent} from '#/components/Portal'
type Props = {
onClose: () => void
onSelectGif: (gif: Gif) => void
disabled?: boolean
Portal?: PortalComponent
}
export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
export function SelectGifBtn({onClose, onSelectGif, disabled, Portal}: Props) {
const {_} = useLingui()
const ref = useRef<{open: () => void}>(null)
const t = useTheme()
@@ -46,6 +48,7 @@ export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
controlRef={ref}
onClose={onClose}
onSelectGif={onSelectGif}
Portal={Portal}
/>
</>
)
@@ -9,6 +9,7 @@ import {isNative} from '#/platform/detection'
import {ComposerImage, createComposerImage} from '#/state/gallery'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
type Props = {
@@ -21,23 +22,26 @@ export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
const {_} = useLingui()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const t = useTheme()
const sheetWrapper = useSheetWrapper()
const onPressSelectPhotos = useCallback(async () => {
if (isNative && !(await requestPhotoAccessIfNeeded())) {
return
}
const images = await openPicker({
selectionLimit: 4 - size,
allowsMultipleSelection: true,
})
const images = await sheetWrapper(
openPicker({
selectionLimit: 4 - size,
allowsMultipleSelection: true,
}),
)
const results = await Promise.all(
images.map(img => createComposerImage(img)),
)
onAdd(results)
}, [requestPhotoAccessIfNeeded, size, onAdd])
}, [requestPhotoAccessIfNeeded, size, onAdd, sheetWrapper])
return (
<Button
+136 -15
View File
@@ -1,17 +1,14 @@
import {ImagePickerAsset} from 'expo-image-picker'
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
import {ComposerImage, createInitialImages} from '#/state/gallery'
import {Gif} from '#/state/queries/tenor'
import {ComposerOpts} from '#/state/shell/composer'
import {createVideoState, VideoAction, videoReducer, VideoState} from './video'
type PostRecord = {
uri: string
}
type ImagesMedia = {
type: 'images'
images: ComposerImage[]
labels: string[]
}
type VideoMedia = {
@@ -19,16 +16,30 @@ type VideoMedia = {
video: VideoState
}
type ComposerEmbed = {
// TODO: Other record types.
record: PostRecord | undefined
// TODO: Other media types.
media: ImagesMedia | VideoMedia | undefined
type GifMedia = {
type: 'gif'
gif: Gif
alt: string
}
type Link = {
type: 'link'
uri: string
}
// This structure doesn't exactly correspond to the data model.
// Instead, it maps to how the UI is organized, and how we present a post.
type EmbedDraft = {
// We'll always submit quote and actual media (images, video, gifs) chosen by the user.
quote: Link | undefined
media: ImagesMedia | VideoMedia | GifMedia | undefined
// This field may end up ignored if we have more important things to display than a link card:
link: Link | undefined
}
export type ComposerState = {
// TODO: Other draft data.
embed: ComposerEmbed
embed: EmbedDraft
}
export type ComposerAction =
@@ -42,6 +53,12 @@ export type ComposerAction =
}
| {type: 'embed_remove_video'}
| {type: 'embed_update_video'; videoAction: VideoAction}
| {type: 'embed_add_uri'; uri: string}
| {type: 'embed_remove_quote'}
| {type: 'embed_remove_link'}
| {type: 'embed_add_gif'; gif: Gif}
| {type: 'embed_update_gif'; alt: string}
| {type: 'embed_remove_gif'}
const MAX_IMAGES = 4
@@ -60,7 +77,6 @@ export function composerReducer(
nextMedia = {
type: 'images',
images: action.images.slice(0, MAX_IMAGES),
labels: [],
}
} else if (prevMedia.type === 'images') {
nextMedia = {
@@ -171,6 +187,102 @@ export function composerReducer(
},
}
}
case 'embed_add_uri': {
const prevQuote = state.embed.quote
const prevLink = state.embed.link
let nextQuote = prevQuote
let nextLink = prevLink
if (isBskyPostUrl(action.uri)) {
if (!prevQuote) {
nextQuote = {
type: 'link',
uri: action.uri,
}
}
} else {
if (!prevLink) {
nextLink = {
type: 'link',
uri: action.uri,
}
}
}
return {
...state,
embed: {
...state.embed,
quote: nextQuote,
link: nextLink,
},
}
}
case 'embed_remove_link': {
return {
...state,
embed: {
...state.embed,
link: undefined,
},
}
}
case 'embed_remove_quote': {
return {
...state,
embed: {
...state.embed,
quote: undefined,
},
}
}
case 'embed_add_gif': {
const prevMedia = state.embed.media
let nextMedia = prevMedia
if (!prevMedia) {
nextMedia = {
type: 'gif',
gif: action.gif,
alt: '',
}
}
return {
...state,
embed: {
...state.embed,
media: nextMedia,
},
}
}
case 'embed_update_gif': {
const prevMedia = state.embed.media
let nextMedia = prevMedia
if (prevMedia?.type === 'gif') {
nextMedia = {
...prevMedia,
alt: action.alt,
}
}
return {
...state,
embed: {
...state.embed,
media: nextMedia,
},
}
}
case 'embed_remove_gif': {
const prevMedia = state.embed.media
let nextMedia = prevMedia
if (prevMedia?.type === 'gif') {
nextMedia = undefined
}
return {
...state,
embed: {
...state.embed,
media: nextMedia,
},
}
}
default:
return state
}
@@ -178,22 +290,31 @@ export function composerReducer(
export function createComposerState({
initImageUris,
initQuoteUri,
}: {
initImageUris: ComposerOpts['imageUris']
initQuoteUri: string | undefined
}): ComposerState {
let media: ImagesMedia | undefined
if (initImageUris?.length) {
media = {
type: 'images',
images: createInitialImages(initImageUris),
labels: [],
}
}
// TODO: initial video.
let quote: Link | undefined
if (initQuoteUri) {
quote = {
type: 'link',
uri: initQuoteUri,
}
}
// TODO: Other initial content.
return {
embed: {
record: undefined,
quote,
media,
link: undefined,
},
}
}
+46 -2
View File
@@ -4,8 +4,6 @@ import {JobStatus} from '@atproto/api/dist/client/types/app/bsky/video/defs'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro'
import {createVideoAgent} from '#/lib/media/video/util'
import {uploadVideo} from '#/lib/media/video/upload'
import {AbortError} from '#/lib/async/cancelable'
import {compressVideo} from '#/lib/media/video/compress'
import {
@@ -14,8 +12,12 @@ import {
VideoTooLargeError,
} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {uploadVideo} from '#/lib/media/video/upload'
import {createVideoAgent} from '#/lib/media/video/util'
import {logger} from '#/logger'
type CaptionsTrack = {lang: string; file: File}
export type VideoAction =
| {
type: 'compressing_to_uploading'
@@ -40,6 +42,16 @@ export type VideoAction =
height: number
signal: AbortSignal
}
| {
type: 'update_alt_text'
altText: string
signal: AbortSignal
}
| {
type: 'update_captions'
updater: (prev: CaptionsTrack[]) => CaptionsTrack[]
signal: AbortSignal
}
| {
type: 'update_job_status'
jobStatus: AppBskyVideoDefs.JobStatus
@@ -57,6 +69,8 @@ export const NO_VIDEO = Object.freeze({
video: undefined,
jobId: undefined,
pendingPublish: undefined,
altText: '',
captions: [],
})
export type NoVideoState = typeof NO_VIDEO
@@ -70,6 +84,8 @@ type ErrorState = {
jobId: string | null
error: string
pendingPublish?: undefined
altText: string
captions: CaptionsTrack[]
}
type CompressingState = {
@@ -80,6 +96,8 @@ type CompressingState = {
video?: undefined
jobId?: undefined
pendingPublish?: undefined
altText: string
captions: CaptionsTrack[]
}
type UploadingState = {
@@ -90,6 +108,8 @@ type UploadingState = {
video: CompressedVideo
jobId?: undefined
pendingPublish?: undefined
altText: string
captions: CaptionsTrack[]
}
type ProcessingState = {
@@ -101,6 +121,8 @@ type ProcessingState = {
jobId: string
jobStatus: AppBskyVideoDefs.JobStatus | null
pendingPublish?: undefined
altText: string
captions: CaptionsTrack[]
}
type DoneState = {
@@ -111,6 +133,8 @@ type DoneState = {
video: CompressedVideo
jobId?: undefined
pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean}
altText: string
captions: CaptionsTrack[]
}
export type VideoState =
@@ -129,6 +153,8 @@ export function createVideoState(
progress: 0,
abortController,
asset,
altText: '',
captions: [],
}
}
@@ -149,6 +175,8 @@ export function videoReducer(
asset: state.asset ?? null,
video: state.video ?? null,
jobId: state.jobId ?? null,
altText: state.altText,
captions: state.captions,
}
} else if (action.type === 'update_progress') {
if (state.status === 'compressing' || state.status === 'uploading') {
@@ -164,6 +192,16 @@ export function videoReducer(
asset: {...state.asset, width: action.width, height: action.height},
}
}
} else if (action.type === 'update_alt_text') {
return {
...state,
altText: action.altText,
}
} else if (action.type === 'update_captions') {
return {
...state,
captions: action.updater(state.captions),
}
} else if (action.type === 'compressing_to_uploading') {
if (state.status === 'compressing') {
return {
@@ -172,6 +210,8 @@ export function videoReducer(
abortController: state.abortController,
asset: state.asset,
video: action.video,
altText: state.altText,
captions: state.captions,
}
}
return state
@@ -185,6 +225,8 @@ export function videoReducer(
video: state.video,
jobId: action.jobId,
jobStatus: null,
altText: state.altText,
captions: state.captions,
}
}
} else if (action.type === 'update_job_status') {
@@ -210,6 +252,8 @@ export function videoReducer(
blobRef: action.blobRef,
mutableProcessed: false,
},
altText: state.altText,
captions: state.captions,
}
}
}
@@ -13,6 +13,7 @@ import * as Dialog from '#/components/Dialog'
import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {PortalComponent} from '#/components/Portal'
export function ThreadgateBtn({
postgate,
@@ -20,6 +21,7 @@ export function ThreadgateBtn({
threadgateAllowUISettings,
onChangeThreadgateAllowUISettings,
style,
Portal,
}: {
postgate: AppBskyFeedPostgate.Record
onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
@@ -28,6 +30,8 @@ export function ThreadgateBtn({
onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
style?: StyleProp<AnimatedStyle<ViewStyle>>
Portal: PortalComponent
}) {
const {_} = useLingui()
const t = useTheme()
@@ -77,6 +81,7 @@ export function ThreadgateBtn({
onChangePostgate={onChangePostgate}
threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings}
Portal={Portal}
/>
</>
)
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
import {MAX_ALT_TEXT} from '#/lib/constants'
import {useEnforceMaxGraphemeCount} from '#/lib/strings/helpers'
import {LANGUAGES} from '#/locale/languages'
import {isAndroid, isWeb} from '#/platform/detection'
import {isWeb} from '#/platform/detection'
import {useLanguagePrefs} from '#/state/preferences'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -17,18 +17,20 @@ import {CC_Stroke2_Corner0_Rounded as CCIcon} from '#/components/icons/CC'
import {PageText_Stroke2_Corner0_Rounded as PageTextIcon} from '#/components/icons/PageText'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {PortalComponent} from '#/components/Portal'
import {Text} from '#/components/Typography'
import {SubtitleFilePicker} from './SubtitleFilePicker'
const MAX_NUM_CAPTIONS = 1
type CaptionsTrack = {lang: string; file: File}
interface Props {
defaultAltText: string
captions: {lang: string; file: File}[]
captions: CaptionsTrack[]
saveAltText: (altText: string) => void
setCaptions: React.Dispatch<
React.SetStateAction<{lang: string; file: File}[]>
>
setCaptions: (updater: (prev: CaptionsTrack[]) => CaptionsTrack[]) => void
Portal: PortalComponent
}
export function SubtitleDialogBtn(props: Props) {
@@ -56,9 +58,7 @@ export function SubtitleDialogBtn(props: Props) {
{isWeb ? <Trans>Captions & alt text</Trans> : <Trans>Alt text</Trans>}
</ButtonText>
</Button>
<Dialog.Outer
control={control}
nativeOptions={isAndroid ? {sheet: {snapPoints: ['60%']}} : {}}>
<Dialog.Outer control={control} Portal={props.Portal}>
<Dialog.Handle />
<SubtitleDialogInner {...props} />
</Dialog.Outer>
@@ -198,9 +198,7 @@ function SubtitleFileRow({
language: string
file: File
otherLanguages: {code2: string; code3: string; name: string}[]
setCaptions: React.Dispatch<
React.SetStateAction<{lang: string; file: File}[]>
>
setCaptions: (updater: (prev: CaptionsTrack[]) => CaptionsTrack[]) => void
style: StyleProp<ViewStyle>
}) {
const {_} = useLingui()
@@ -48,7 +48,7 @@ export function PostThreadComposePrompt({
accessibilityHint={_(msg`Opens composer`)}
style={[
gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11},
gtMobile ? {paddingLeft: 6, paddingRight: 6} : a.px_sm,
a.px_sm,
a.border_t,
t.atoms.border_contrast_low,
t.atoms.bg,
@@ -1,14 +1,9 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {s} from '#/lib/styles'
import {logger} from '#/logger'
import {Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
import {
@@ -16,8 +11,11 @@ import {
useProfileQuery,
} from '#/state/queries/profile'
import {useRequireAuth} from '#/state/session'
import {Text} from '#/view/com/util/text/Text'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
export function PostThreadFollowBtn({did}: {did: string}) {
const {data: profile, isLoading} = useProfileQuery({did})
@@ -36,9 +34,7 @@ function PostThreadFollowBtnLoaded({
}) {
const navigation = useNavigation()
const {_} = useLingui()
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const {isTabletOrDesktop} = useWebMediaQueries()
const {gtMobile} = useBreakpoints()
const profile: Shadow<AppBskyActorDefs.ProfileViewBasic> =
useProfileShadow(profileUnshadowed)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
@@ -113,51 +109,32 @@ function PostThreadFollowBtnLoaded({
if (!showFollowBtn) return null
return (
<View style={{width: isTabletOrDesktop ? 130 : 120}}>
<View style={styles.btnOuter}>
<TouchableOpacity
testID="followBtn"
onPress={onPress}
style={[styles.btn, !isFollowing ? palInverted.view : pal.viewLight]}
accessibilityRole="button"
accessibilityLabel={_(msg`Follow ${profile.handle}`)}
accessibilityHint={_(
msg`Shows posts from ${profile.handle} in your feed`,
)}>
{isTabletOrDesktop && (
<FontAwesomeIcon
icon={!isFollowing ? 'plus' : 'check'}
style={[!isFollowing ? palInverted.text : pal.text, s.mr5]}
/>
)}
<Text
type="button"
style={[!isFollowing ? palInverted.text : pal.text, s.bold]}
numberOfLines={1}>
{!isFollowing ? (
isFollowedBy ? (
<Trans>Follow Back</Trans>
) : (
<Trans>Follow</Trans>
)
) : (
<Trans>Following</Trans>
)}
</Text>
</TouchableOpacity>
</View>
</View>
<Button
testID="followBtn"
label={_(msg`Follow ${profile.handle}`)}
onPress={onPress}
size="small"
variant="solid"
color="secondary_inverted"
style={[a.rounded_full]}>
{gtMobile && (
<ButtonIcon
icon={isFollowing ? Check : Plus}
position="left"
size="sm"
/>
)}
<ButtonText>
{!isFollowing ? (
isFollowedBy ? (
<Trans>Follow Back</Trans>
) : (
<Trans>Follow</Trans>
)
) : (
<Trans>Following</Trans>
)}
</ButtonText>
</Button>
)
}
const styles = StyleSheet.create({
btnOuter: {
marginLeft: 'auto',
},
btn: {
flexDirection: 'row',
borderRadius: 50,
paddingVertical: 8,
paddingHorizontal: 14,
},
})
+137 -208
View File
@@ -14,14 +14,12 @@ import {useLingui} from '@lingui/react'
import {MAX_POST_LINES} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {countLines} from '#/lib/strings/helpers'
import {niceDate} from '#/lib/strings/time'
import {s} from '#/lib/styles'
import {isWeb} from '#/platform/detection'
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {useLanguagePrefs} from '#/state/preferences'
import {useOpenLink} from '#/state/preferences/in-app-browser'
@@ -30,9 +28,10 @@ import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
import {atoms as a} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {AppModerationCause} from '#/components/Pills'
import {RichText} from '#/components/RichText'
import {Text as NewText} from '#/components/Typography'
import {ContentHider} from '../../../components/moderation/ContentHider'
import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
import {PostAlerts} from '../../../components/moderation/PostAlerts'
@@ -180,6 +179,7 @@ let PostThreadItemLoaded = ({
hideTopBorder?: boolean
threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => {
const t = useTheme()
const pal = usePalette('default')
const {_, i18n} = useLingui()
const langPrefs = useLanguagePrefs()
@@ -268,8 +268,14 @@ let PostThreadItemLoaded = ({
return (
<>
{rootUri !== post.uri && (
<View style={{paddingLeft: 16, flexDirection: 'row', height: 16}}>
<View style={{width: 38}}>
<View
style={[
a.pl_lg,
a.flex_row,
a.pb_xs,
{height: a.pt_lg.paddingTop},
]}>
<View style={{width: 42}}>
<View
style={[
styles.replyLine,
@@ -286,88 +292,74 @@ let PostThreadItemLoaded = ({
<View
testID={`postThreadItem-by-${post.author.handle}`}
style={[
styles.outer,
styles.outerHighlighted,
pal.border,
pal.view,
rootUri === post.uri && styles.outerHighlightedRoot,
hideTopBorder && styles.noTopBorder,
]}
accessible={false}>
<View style={[styles.layout]}>
<View style={[styles.layoutAvi, {paddingBottom: 8}]}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
/>
</View>
<View style={styles.layoutContent}>
<View
style={[styles.meta, styles.metaExpandedLine1, {zIndex: 1}]}>
<Link style={s.flex1} href={authorHref} title={authorTitle}>
<Text
emoji
type="xl-bold"
style={[pal.text, a.self_start]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
</Text>
</Link>
</View>
<View style={styles.meta}>
<Link style={s.flex1} href={authorHref} title={authorTitle}>
<Text
emoji
type="md"
style={[pal.textLight]}
numberOfLines={1}>
{sanitizeHandle(post.author.handle, '@')}
</Text>
</Link>
</View>
a.px_lg,
t.atoms.border_contrast_low,
// root post styles
rootUri === post.uri && [a.pt_lg],
]}>
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
/>
<View style={[a.flex_1]}>
<Link style={s.flex1} href={authorHref} title={authorTitle}>
<NewText
emoji
style={[a.text_lg, a.font_bold, a.leading_snug, a.self_start]}
numberOfLines={1}>
{sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
</NewText>
</Link>
<Link style={s.flex1} href={authorHref} title={authorTitle}>
<NewText
emoji
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{sanitizeHandle(post.author.handle, '@')}
</NewText>
</Link>
</View>
{currentAccount?.did !== post.author.did && (
<PostThreadFollowBtn did={post.author.did} />
<View>
<PostThreadFollowBtn did={post.author.did} />
</View>
)}
</View>
<View style={[s.pl10, s.pr10, s.pb10]}>
<LabelsOnMyPost post={post} />
<View style={[a.pb_sm]}>
<LabelsOnMyPost post={post} style={[a.pb_sm]} />
<ContentHider
modui={moderation.ui('contentView')}
ignoreMute
style={styles.contentHider}
childContainerStyle={styles.contentHiderChild}>
childContainerStyle={[a.pt_sm]}>
<PostAlerts
modui={moderation.ui('contentView')}
size="lg"
includeMute
style={[a.pt_2xs, a.pb_sm]}
style={[a.pb_sm]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<View
style={[
styles.postTextContainer,
styles.postTextLargeContainer,
]}>
<RichText
enableTags
selectable
value={richText}
style={[a.flex_1, a.text_xl]}
authorHandle={post.author.handle}
/>
</View>
<RichText
enableTags
selectable
value={richText}
style={[a.flex_1, a.text_xl]}
authorHandle={post.author.handle}
/>
) : undefined}
{post.embed && (
<View style={[a.pb_sm]}>
<View style={[a.py_xs]}>
<PostEmbeds
embed={post.embed}
moderation={moderation}
@@ -386,68 +378,73 @@ let PostThreadItemLoaded = ({
post.likeCount !== 0 ||
post.quoteCount !== 0 ? (
// Show this section unless we're *sure* it has no engagement.
<View style={[styles.expandedInfo, pal.border]}>
<View
style={[
a.flex_row,
a.align_center,
a.gap_lg,
a.border_t,
a.border_b,
a.mt_md,
a.py_md,
t.atoms.border_contrast_low,
]}>
{post.repostCount != null && post.repostCount !== 0 ? (
<Link
style={styles.expandedInfoItem}
href={repostsHref}
title={repostsTitle}>
<Text
<Link href={repostsHref} title={repostsTitle}>
<NewText
testID="repostCount-expanded"
type="lg"
style={pal.textLight}>
<Text type="xl-bold" style={pal.text}>
style={[a.text_md, t.atoms.text_contrast_medium]}>
<NewText style={[a.text_md, a.font_bold, t.atoms.text]}>
{formatCount(i18n, post.repostCount)}
</Text>{' '}
</NewText>{' '}
<Plural
value={post.repostCount}
one="repost"
other="reposts"
/>
</Text>
</NewText>
</Link>
) : null}
{post.quoteCount != null &&
post.quoteCount !== 0 &&
!post.viewer?.embeddingDisabled ? (
<Link
style={styles.expandedInfoItem}
href={quotesHref}
title={quotesTitle}>
<Text
<Link href={quotesHref} title={quotesTitle}>
<NewText
testID="quoteCount-expanded"
type="lg"
style={pal.textLight}>
<Text type="xl-bold" style={pal.text}>
style={[a.text_md, t.atoms.text_contrast_medium]}>
<NewText style={[a.text_md, a.font_bold, t.atoms.text]}>
{formatCount(i18n, post.quoteCount)}
</Text>{' '}
</NewText>{' '}
<Plural
value={post.quoteCount}
one="quote"
other="quotes"
/>
</Text>
</NewText>
</Link>
) : null}
{post.likeCount != null && post.likeCount !== 0 ? (
<Link
style={styles.expandedInfoItem}
href={likesHref}
title={likesTitle}>
<Text
<Link href={likesHref} title={likesTitle}>
<NewText
testID="likeCount-expanded"
type="lg"
style={pal.textLight}>
<Text type="xl-bold" style={pal.text}>
style={[a.text_md, t.atoms.text_contrast_medium]}>
<NewText style={[a.text_md, a.font_bold, t.atoms.text]}>
{formatCount(i18n, post.likeCount)}
</Text>{' '}
</NewText>{' '}
<Plural value={post.likeCount} one="like" other="likes" />
</Text>
</NewText>
</Link>
) : null}
</View>
) : null}
<View style={[s.pl10, s.pr10]}>
<View
style={[
a.pt_sm,
a.pb_2xs,
{
marginLeft: -5,
},
]}>
<PostCtrls
big
post={post}
@@ -481,9 +478,8 @@ let PostThreadItemLoaded = ({
testID={`postThreadItem-by-${post.author.handle}`}
href={postHref}
disabled={overrideBlur}
style={[pal.view]}
modui={moderation.ui('contentList')}
iconSize={isThreadedChild ? 26 : 38}
iconSize={isThreadedChild ? 24 : 42}
iconStyles={
isThreadedChild ? {marginRight: 4} : {marginLeft: 2, marginRight: 2}
}
@@ -496,7 +492,7 @@ let PostThreadItemLoaded = ({
paddingLeft: 8,
height: isThreadedChildAdjacentTop ? 8 : 16,
}}>
<View style={{width: 38}}>
<View style={{width: 42}}>
{!isThreadedChild && showParentReplyLine && (
<View
style={[
@@ -514,7 +510,9 @@ let PostThreadItemLoaded = ({
<View
style={[
styles.layout,
a.flex_row,
a.px_sm,
a.gap_md,
{
paddingBottom:
showChildReplyLine && !isThreadedChild
@@ -526,9 +524,9 @@ let PostThreadItemLoaded = ({
]}>
{/* If we are in threaded mode, the avatar is rendered in PostMeta */}
{!isThreadedChild && (
<View style={styles.layoutAvi}>
<View>
<PreviewableUserAvatar
size={38}
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
@@ -549,12 +547,7 @@ let PostThreadItemLoaded = ({
</View>
)}
<View
style={
isThreadedChild
? styles.layoutContentThreaded
: styles.layoutContent
}>
<View style={[a.flex_1]}>
<PostMeta
author={post.author}
moderation={moderation}
@@ -563,20 +556,16 @@ let PostThreadItemLoaded = ({
showAvatar={isThreadedChild}
avatarModeration={moderation.ui('avatar')}
avatarSize={24}
style={
isThreadedChild && {
paddingBottom: isWeb ? 5 : 4,
}
}
style={[a.pb_xs]}
/>
<LabelsOnMyPost post={post} />
<LabelsOnMyPost post={post} style={[a.pb_xs]} />
<PostAlerts
modui={moderation.ui('contentList')}
style={[a.pt_2xs, a.pb_2xs]}
style={[a.pb_2xs]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<View style={styles.postTextContainer}>
<View style={[a.pb_2xs, a.pr_sm]}>
<RichText
enableTags
value={richText}
@@ -659,29 +648,31 @@ function PostOuterWrapper({
hasPrecedingItem: boolean
hideTopBorder?: boolean
}>) {
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
const t = useTheme()
if (treeView && depth > 0) {
return (
<View
style={[
pal.border,
a.flex_row,
a.px_sm,
t.atoms.border_contrast_low,
styles.cursor,
{
flexDirection: 'row',
paddingHorizontal: isMobile ? 10 : 6,
borderTopWidth: depth === 1 ? StyleSheet.hairlineWidth : 0,
borderTopWidth: depth === 1 ? a.border_t.borderTopWidth : 0,
},
]}>
{Array.from(Array(depth - 1)).map((_, n: number) => (
<View
key={`${post.uri}-padding-${n}`}
style={{
borderLeftWidth: 2,
borderLeftColor: pal.colors.border,
marginLeft: isMobile ? 6 : 12,
paddingLeft: isMobile ? 6 : 8,
}}
style={[
a.ml_sm,
t.atoms.border_contrast_low,
{
borderLeftWidth: 2,
paddingLeft: a.pl_sm.paddingLeft - 2, // minus border
},
]}
/>
))}
<View style={{flex: 1}}>{children}</View>
@@ -691,8 +682,9 @@ function PostOuterWrapper({
return (
<View
style={[
styles.outer,
pal.border,
a.border_t,
a.px_sm,
t.atoms.border_contrast_low,
showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
hideTopBorder && styles.noTopBorder,
styles.cursor,
@@ -713,6 +705,7 @@ function ExpandedPostDetails({
needsTranslation: boolean
translatorUrl: string
}) {
const t = useTheme()
const pal = usePalette('default')
const {_, i18n} = useLingui()
const openLink = useOpenLink()
@@ -723,31 +716,25 @@ function ExpandedPostDetails({
}, [openLink, translatorUrl])
return (
<View
style={[
a.flex_row,
a.align_center,
a.flex_wrap,
a.gap_xs,
s.mt2,
s.mb10,
]}>
<Text style={[a.text_sm, pal.textLight]}>
<View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm, a.pt_md]}>
<NewText style={[a.text_sm, t.atoms.text_contrast_medium]}>
{niceDate(i18n, post.indexedAt)}
</Text>
</NewText>
{isRootPost && (
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
)}
{needsTranslation && (
<>
<Text style={[a.text_sm, pal.textLight]}>&middot;</Text>
<NewText style={[a.text_sm, t.atoms.text_contrast_medium]}>
&middot;
</NewText>
<Text
<NewText
style={[a.text_sm, pal.link]}
title={_(msg`Translate`)}
onPress={onTranslatePress}>
<Trans>Translate</Trans>
</Text>
</NewText>
</>
)}
</View>
@@ -773,31 +760,9 @@ const styles = StyleSheet.create({
borderTopWidth: StyleSheet.hairlineWidth,
paddingLeft: 8,
},
outerHighlighted: {
borderTopWidth: 0,
paddingTop: 4,
paddingLeft: 8,
paddingRight: 8,
},
outerHighlightedRoot: {
borderTopWidth: StyleSheet.hairlineWidth,
paddingTop: 16,
},
noTopBorder: {
borderTopWidth: 0,
},
layout: {
flexDirection: 'row',
paddingHorizontal: 8,
},
layoutAvi: {},
layoutContent: {
flex: 1,
marginLeft: 10,
},
layoutContentThreaded: {
flex: 1,
},
meta: {
flexDirection: 'row',
paddingVertical: 2,
@@ -805,42 +770,6 @@ const styles = StyleSheet.create({
metaExpandedLine1: {
paddingVertical: 0,
},
alert: {
marginBottom: 6,
},
postTextContainer: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
paddingBottom: 4,
paddingRight: 10,
overflow: 'hidden',
},
postTextLargeContainer: {
paddingHorizontal: 0,
paddingRight: 0,
paddingBottom: 10,
},
translateLink: {
marginBottom: 6,
},
contentHider: {
marginBottom: 6,
},
contentHiderChild: {
marginTop: 6,
},
expandedInfo: {
flexDirection: 'row',
padding: 10,
borderTopWidth: StyleSheet.hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
marginTop: 5,
marginBottom: 10,
},
expandedInfoItem: {
marginRight: 10,
},
loadMore: {
flexDirection: 'row',
alignItems: 'center',
+8 -4
View File
@@ -20,6 +20,7 @@ import {isAndroid, isNative, isWeb} from '#/platform/detection'
import {precacheProfile} from '#/state/queries/profile'
import {HighPriorityImage} from '#/view/com/util/images/Image'
import {tokens, useTheme} from '#/alf'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
Camera_Stroke2_Corner0_Rounded as Camera,
@@ -271,6 +272,7 @@ let EditableUserAvatar = ({
const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const sheetWrapper = useSheetWrapper()
const aviStyle = useMemo(() => {
if (type === 'algo' || type === 'list') {
@@ -306,9 +308,11 @@ let EditableUserAvatar = ({
return
}
const items = await openPicker({
aspect: [1, 1],
})
const items = await sheetWrapper(
openPicker({
aspect: [1, 1],
}),
)
const item = items[0]
if (!item) {
return
@@ -332,7 +336,7 @@ let EditableUserAvatar = ({
logger.error('Failed to crop banner', {error: e})
}
}
}, [onSelectNewAvatar, requestPhotoAccessIfNeeded])
}, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper])
const onRemoveAvatar = React.useCallback(() => {
onSelectNewAvatar(null)
+4 -2
View File
@@ -17,6 +17,7 @@ import {logger} from '#/logger'
import {isAndroid, isNative} from '#/platform/detection'
import {EventStopper} from '#/view/com/util/EventStopper'
import {tokens, useTheme as useAlfTheme} from '#/alf'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
Camera_Stroke2_Corner0_Rounded as Camera,
@@ -43,6 +44,7 @@ export function UserBanner({
const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const sheetWrapper = useSheetWrapper()
const onOpenCamera = React.useCallback(async () => {
if (!(await requestCameraAccessIfNeeded())) {
@@ -60,7 +62,7 @@ export function UserBanner({
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker()
const items = await sheetWrapper(openPicker())
if (!items[0]) {
return
}
@@ -80,7 +82,7 @@ export function UserBanner({
logger.error('Failed to crop banner', {error: e})
}
}
}, [onSelectNewBanner, requestPhotoAccessIfNeeded])
}, [onSelectNewBanner, requestPhotoAccessIfNeeded, sheetWrapper])
const onRemoveBanner = React.useCallback(() => {
onSelectNewBanner?.(null)
+1 -1
View File
@@ -100,7 +100,7 @@ export function ViewHeader({
</TouchableOpacity>
) : null}
<View style={styles.titleContainer} pointerEvents="none">
<Text type="title" style={[pal.text, styles.title]}>
<Text emoji type="title" style={[pal.text, styles.title]}>
{title}
</Text>
</View>
+9 -7
View File
@@ -240,8 +240,8 @@ let PostDropdownBtn = ({
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}, [_, richText])
const onPressTranslate = React.useCallback(() => {
openLink(translatorUrl)
const onPressTranslate = React.useCallback(async () => {
await openLink(translatorUrl)
}, [openLink, translatorUrl])
const onHidePost = React.useCallback(() => {
@@ -439,7 +439,7 @@ let PostDropdownBtn = ({
<Menu.Item
testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)}
onPress={sendViaChatControl.open}>
onPress={() => sendViaChatControl.open()}>
<Menu.ItemText>
<Trans>Send via direct message</Trans>
</Menu.ItemText>
@@ -467,7 +467,7 @@ let PostDropdownBtn = ({
<Menu.Item
testID="postDropdownEmbedBtn"
label={_(msg`Embed post`)}
onPress={embedPostControl.open}>
onPress={() => embedPostControl.open()}>
<Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
<Menu.ItemIcon icon={CodeBrackets} position="right" />
</Menu.Item>
@@ -542,7 +542,7 @@ let PostDropdownBtn = ({
? _(msg`Hide reply for me`)
: _(msg`Hide post for me`)
}
onPress={hidePromptControl.open}>
onPress={() => hidePromptControl.open()}>
<Menu.ItemText>
{isReply
? _(msg`Hide reply for me`)
@@ -630,7 +630,9 @@ let PostDropdownBtn = ({
<Menu.Item
testID="postDropdownEditPostInteractions"
label={_(msg`Edit interaction settings`)}
onPress={postInteractionSettingsDialogControl.open}
onPress={() =>
postInteractionSettingsDialogControl.open()
}
{...(isAuthor
? Platform.select({
web: {
@@ -649,7 +651,7 @@ let PostDropdownBtn = ({
<Menu.Item
testID="postDropdownDeleteBtn"
label={_(msg`Delete post`)}
onPress={deletePromptControl.open}>
onPress={() => deletePromptControl.open()}>
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
@@ -86,7 +86,9 @@ let RepostButton = ({
</Text>
) : undefined}
</Button>
<Dialog.Outer control={dialogControl}>
<Dialog.Outer
control={dialogControl}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.Inner label={_(msg`Repost or quote post`)}>
<View style={a.gap_xl}>
@@ -155,7 +157,6 @@ let RepostButton = ({
</View>
<Button
label={_(msg`Cancel quote post`)}
onAccessibilityEscape={close}
onPress={close}
size="large"
variant="solid"
+18 -13
View File
@@ -77,13 +77,16 @@ export function Text({
flattened.fontSize = flattened.fontSize * fonts.scaleMultiplier
}
const shared = {
uiTextView: true,
selectable,
style: flattened,
...props,
}
return (
<UITextView
style={flattened}
selectable={selectable}
uiTextView
{...props}>
{isIOS && emoji ? renderChildrenWithEmoji(children) : children}
<UITextView {...shared}>
{isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children}
</UITextView>
)
}
@@ -104,14 +107,16 @@ export function Text({
flattened.fontSize = flattened.fontSize * fonts.scaleMultiplier
}
const shared = {
selectable,
style: flattened,
dataSet: Object.assign({tooltip: title}, dataSet || {}),
...props,
}
return (
<RNText
style={flattened}
// @ts-ignore web only -esb
dataSet={Object.assign({tooltip: title}, dataSet || {})}
selectable={selectable}
{...props}>
{isIOS && emoji ? renderChildrenWithEmoji(children) : children}
<RNText {...shared}>
{isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children}
</RNText>
)
}
@@ -79,7 +79,6 @@ export function DisableEmail2FADialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title">
@@ -53,7 +53,6 @@ export function ExportCarDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title">
+2 -12
View File
@@ -2,8 +2,8 @@ import React from 'react'
import {View} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {useDialogStateControlContext} from '#/state/dialogs'
import {NavigationProp} from 'lib/routes/types'
import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -179,19 +179,13 @@ export function Dialogs() {
</Prompt.Outer>
<Dialog.Outer control={basic}>
<Dialog.Handle />
<Dialog.Inner label="test">
<H3 nativeID="dialog-title">Dialog</H3>
<P nativeID="dialog-description">A basic dialog</P>
</Dialog.Inner>
</Dialog.Outer>
<Dialog.Outer
control={scrollable}
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<Dialog.Handle />
<Dialog.Outer control={scrollable}>
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title">
@@ -230,8 +224,6 @@ export function Dialogs() {
</Dialog.Outer>
<Dialog.Outer control={testDialog}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title">
@@ -356,8 +348,6 @@ export function Dialogs() {
{shouldRenderUnmountTest && (
<Dialog.Outer control={unmountTestDialog}>
<Dialog.Handle />
<Dialog.Inner label="test">
<H3 nativeID="dialog-title">Unmount Test Dialog</H3>
<P nativeID="dialog-description">Will unmount in about 5 seconds</P>
+24 -53
View File
@@ -1,19 +1,28 @@
import React, {useLayoutEffect} from 'react'
import React from 'react'
import {Modal, View} from 'react-native'
import {StatusBar} from 'expo-status-bar'
import * as SystemUI from 'expo-system-ui'
import {useDialogStateControlContext} from '#/state/dialogs'
import {useComposerState} from '#/state/shell/composer'
import {atoms as a, useTheme} from '#/alf'
import {getBackgroundColor, useThemeName} from '#/alf/util/useColorModeTheme'
import {ComposePost, useComposerCancelRef} from '../com/composer/Composer'
export function Composer({}: {winHeight: number}) {
const {setFullyExpandedCount} = useDialogStateControlContext()
const t = useTheme()
const state = useComposerState()
const ref = useComposerCancelRef()
const open = !!state
const prevOpen = React.useRef(open)
React.useEffect(() => {
if (open && !prevOpen.current) {
setFullyExpandedCount(c => c + 1)
} else if (!open && prevOpen.current) {
setFullyExpandedCount(c => c - 1)
}
prevOpen.current = open
}, [open, setFullyExpandedCount])
return (
<Modal
@@ -24,56 +33,18 @@ export function Composer({}: {winHeight: number}) {
animationType="slide"
onRequestClose={() => ref.current?.onPressCancel()}>
<View style={[t.atoms.bg, a.flex_1]}>
<Providers open={open}>
<ComposePost
cancelRef={ref}
replyTo={state?.replyTo}
onPost={state?.onPost}
quote={state?.quote}
quoteCount={state?.quoteCount}
mention={state?.mention}
text={state?.text}
imageUris={state?.imageUris}
videoUri={state?.videoUri}
/>
</Providers>
<ComposePost
cancelRef={ref}
replyTo={state?.replyTo}
onPost={state?.onPost}
quote={state?.quote}
quoteCount={state?.quoteCount}
mention={state?.mention}
text={state?.text}
imageUris={state?.imageUris}
videoUri={state?.videoUri}
/>
</View>
</Modal>
)
}
function Providers({
children,
open,
}: {
children: React.ReactNode
open: boolean
}) {
// on iOS, it's a native formSheet. We use FullWindowOverlay to make
// the dialogs appear over it
return (
<>
{children}
<IOSModalBackground active={open} />
</>
)
}
// Generally, the backdrop of the app is the theme color, but when this is open
// we want it to be black due to the modal being a form sheet.
function IOSModalBackground({active}: {active: boolean}) {
const theme = useThemeName()
useLayoutEffect(() => {
SystemUI.setBackgroundColorAsync('black')
return () => {
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
}
}, [theme])
// Set the status bar to light - however, only if the modal is active
// If we rely on this component being mounted to set this,
// there'll be a delay before it switches back to default.
return active ? <StatusBar style="light" animated /> : null
}
+21 -16
View File
@@ -13,6 +13,14 @@ import * as NavigationBar from 'expo-navigation-bar'
import {StatusBar} from 'expo-status-bar'
import {useNavigation, useNavigationState} from '@react-navigation/native'
import {useDedupe} from '#/lib/hooks/useDedupe'
import {useNotificationsHandler} from '#/lib/hooks/useNotificationHandler'
import {usePalette} from '#/lib/hooks/usePalette'
import {useNotificationsRegistration} from '#/lib/notifications/notifications'
import {isStateAtTabRoot} from '#/lib/routes/helpers'
import {useTheme} from '#/lib/ThemeContext'
import {isAndroid, isIOS} from '#/platform/detection'
import {useDialogStateControlContext} from '#/state/dialogs'
import {useSession} from '#/state/session'
import {
useIsDrawerOpen,
@@ -20,17 +28,9 @@ import {
useSetDrawerOpen,
} from '#/state/shell'
import {useCloseAnyActiveElement} from '#/state/util'
import {useDedupe} from 'lib/hooks/useDedupe'
import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler'
import {usePalette} from 'lib/hooks/usePalette'
import {useNotificationsRegistration} from 'lib/notifications/notifications'
import {isStateAtTabRoot} from 'lib/routes/helpers'
import {useTheme} from 'lib/ThemeContext'
import {isAndroid} from 'platform/detection'
import {useDialogStateContext} from 'state/dialogs'
import {Lightbox} from 'view/com/lightbox/Lightbox'
import {ModalsContainer} from 'view/com/modals/Modal'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {Lightbox} from '#/view/com/lightbox/Lightbox'
import {ModalsContainer} from '#/view/com/modals/Modal'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin'
import {Outlet as PortalOutlet} from '#/components/Portal'
@@ -61,7 +61,6 @@ function ShellInner() {
const canGoBack = useNavigationState(state => !isStateAtTabRoot(state))
const {hasSession} = useSession()
const closeAnyActiveElement = useCloseAnyActiveElement()
const {importantForAccessibility} = useDialogStateContext()
useNotificationsRegistration()
useNotificationsHandler()
@@ -101,9 +100,7 @@ function ShellInner() {
return (
<>
<Animated.View
style={containerPadding}
importantForAccessibility={importantForAccessibility}>
<Animated.View style={containerPadding}>
<ErrorBoundary>
<Drawer
renderDrawerContent={renderDrawerContent}
@@ -127,6 +124,7 @@ function ShellInner() {
}
export const Shell: React.FC = function ShellImpl() {
const {fullyExpandedCount} = useDialogStateControlContext()
const pal = usePalette('default')
const theme = useTheme()
React.useEffect(() => {
@@ -140,7 +138,14 @@ export const Shell: React.FC = function ShellImpl() {
}, [theme])
return (
<View testID="mobileShellView" style={[styles.outerContainer, pal.view]}>
<StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} />
<StatusBar
style={
theme.colorScheme === 'dark' || (isIOS && fullyExpandedCount > 0)
? 'light'
: 'dark'
}
animated
/>
<RoutesContainer>
<ShellInner />
</RoutesContainer>
+14
View File
@@ -29,6 +29,20 @@
*
* THIS NEEDS TO BE DUPLICATED IN `bskyweb/templates/base.html`
*/
@font-face {
font-family: 'InterVariable';
src: url(/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf) format('truetype');
font-weight: 300 1000;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'InterVariableItalic';
src: url(/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf) format('truetype');
font-weight: 300 1000;
font-style: italic;
font-display: swap;
}
html {
background-color: white;
scrollbar-gutter: stable both-edges;
+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"
integrity sha512-R+gFGn0x5CWl4OVlk2j1bJTJIz4KO8mPoCHpRHmfqMjmrMvrOM0qQSY3V5NHXwp1yT/L2v8aUmFQsBRIdvi1XA==
expo-system-ui@~3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/expo-system-ui/-/expo-system-ui-3.0.4.tgz#5ace49d38eb03c09a8041b3b82c581a6b974741a"
integrity sha512-v1n6hBO30k9qw6RE8/au4yNoovs71ExGuXizJUlR5KSo4Ruogpb+0/2q3uRZMDIYWWCANvms8L0UOh6fQJ5TXg==
dependencies:
"@react-native/normalize-colors" "~0.74.83"
debug "^4.3.2"
expo-task-manager@~11.8.1:
version "11.8.1"
resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-11.8.1.tgz#33089e78ee3fbd83327fb403bce12d69baf7d21b"
@@ -18057,15 +18049,14 @@ react-native-drawer-layout@^4.0.0-alpha.3:
dependencies:
use-latest-callback "^0.1.9"
react-native-gesture-handler@~2.16.2:
version "2.16.2"
resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.16.2.tgz#032bd2a07334292d7f6cff1dc9d1ec928f72e26d"
integrity sha512-vGFlrDKlmyI+BT+FemqVxmvO7nqxU33cgXVsn6IKAFishvlG3oV2Ds67D5nPkHMea8T+s1IcuMm0bF8ntZtAyg==
react-native-gesture-handler@2.20.0:
version "2.20.0"
resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.20.0.tgz#2d9ec4e9bd22619ebe36269dda3ecb1173928276"
integrity sha512-rFKqgHRfxQ7uSAivk8vxCiW4SB3G0U7jnv7kZD4Y90K5kp6YrU8Q3tWhxe3Rx55BIvSd3mBe9ZWbWVJ0FsSHPA==
dependencies:
"@egjs/hammerjs" "^2.0.17"
hoist-non-react-statics "^3.3.0"
invariant "^2.2.4"
lodash "^4.17.21"
prop-types "^15.7.2"
react-native-get-random-values@^1.6.0:
@@ -18094,10 +18085,10 @@ react-native-ios-context-menu@^1.15.3:
dependencies:
"@dominicstop/ts-event-emitter" "^1.1.0"
react-native-keyboard-controller@^1.12.1:
version "1.12.1"
resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.1.tgz#6de22ed4d060528a0dd25621eeaa7f71772ce35f"
integrity sha512-2OpQcesiYsMilrTzgcTafSGexd9UryRQRuHudIcOn0YaqvvzNpnhVZMVuJMH93fJv/iaZYp3138rgUKOdHhtSw==
react-native-keyboard-controller@^1.14.0:
version "1.14.0"
resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.14.0.tgz#f6faaa12b3736a10f4eec4236ed5b0343508b9a1"
integrity sha512-JW9k2fehFXOpvLWh1YcgyubLodg/HPi6bR11sCZB/BOawf1tnbGnqk967B8XkxDOKHH6mg+z82quCvv8ALh1rg==
react-native-mmkv@^2.12.2:
version "2.12.2"