Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e981e4645c | |||
| 82503dd05e | |||
| 84c75aea23 | |||
| 790c36b0b7 | |||
| b6876c6a58 | |||
| 52f4cb0b73 | |||
| a5181c785a | |||
| 0a7554d5c1 | |||
| 109488fe09 | |||
| b6ca2fe5b4 |
+1
-6
@@ -55,7 +55,6 @@ module.exports = function (_config) {
|
||||
icon: './assets/app-icons/ios_icon_default_next.png',
|
||||
userInterfaceStyle: 'automatic',
|
||||
primaryColor: '#006AFF',
|
||||
newArchEnabled: false,
|
||||
ios: {
|
||||
supportsTablet: false,
|
||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||
@@ -182,10 +181,6 @@ module.exports = function (_config) {
|
||||
androidStatusBar: {
|
||||
barStyle: 'light-content',
|
||||
},
|
||||
// Dark nav bar in light mode is better than light nav bar in dark mode
|
||||
androidNavigationBar: {
|
||||
barStyle: 'light-content',
|
||||
},
|
||||
android: {
|
||||
icon: './assets/app-icons/android_icon_default_next.png',
|
||||
adaptiveIcon: {
|
||||
@@ -259,7 +254,7 @@ module.exports = function (_config) {
|
||||
'expo-build-properties',
|
||||
{
|
||||
ios: {
|
||||
deploymentTarget: '15.1',
|
||||
deploymentTarget: '16.4',
|
||||
buildReactNativeFromSource: true,
|
||||
ccacheEnabled: IS_DEV,
|
||||
cxxLanguageStandard: 'c++23',
|
||||
|
||||
+1
-14
@@ -18,19 +18,6 @@ module.exports = function (api) {
|
||||
plugins: [
|
||||
'@lingui/babel-plugin-lingui-macro',
|
||||
['babel-plugin-react-compiler', {target: '19'}],
|
||||
[
|
||||
'module:react-native-dotenv',
|
||||
{
|
||||
envName: 'APP_ENV',
|
||||
moduleName: '@env',
|
||||
path: '.env',
|
||||
blocklist: null,
|
||||
allowlist: null,
|
||||
safe: false,
|
||||
allowUndefined: true,
|
||||
verbose: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
'module-resolver',
|
||||
{
|
||||
@@ -41,7 +28,7 @@ module.exports = function (api) {
|
||||
},
|
||||
},
|
||||
],
|
||||
'react-native-reanimated/plugin', // NOTE: this plugin MUST be last
|
||||
'react-native-worklets/plugin', // NOTE: this plugin MUST be last
|
||||
],
|
||||
env: {
|
||||
production: {
|
||||
|
||||
@@ -6,7 +6,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
// Views
|
||||
private var sheetVc: SheetViewController?
|
||||
private var innerView: UIView?
|
||||
private var touchHandler: RCTTouchHandler?
|
||||
private var touchHandler: RCTSurfaceTouchHandler?
|
||||
|
||||
// Native content height observation (eliminates JS bridge round-trip)
|
||||
private var contentHeightObservation: NSKeyValueObservation?
|
||||
@@ -78,33 +78,35 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
required init (appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
self.maxHeight = Util.getScreenHeight()
|
||||
self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge)
|
||||
self.touchHandler = RCTSurfaceTouchHandler()
|
||||
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
|
||||
|
||||
override func mountChildComponentView(
|
||||
_ childComponentView: UIView,
|
||||
index: Int
|
||||
) {
|
||||
self.innerView = childComponentView
|
||||
touchHandler?.attach(to: childComponentView)
|
||||
}
|
||||
|
||||
override func unmountChildComponentView(
|
||||
_ childComponentView: UIView,
|
||||
index: Int
|
||||
) {
|
||||
touchHandler?.detach(from: childComponentView)
|
||||
if self.innerView === childComponentView {
|
||||
self.innerView = nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -114,7 +116,6 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
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)
|
||||
@@ -143,8 +144,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
|
||||
if #available(iOS 26.0, *),
|
||||
let tag = self.sourceViewTag,
|
||||
let bridge = self.appContext?.reactBridge,
|
||||
let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: tag)) {
|
||||
let sourceView = self.appContext?.findView(withTag: tag, ofType: UIView.self) {
|
||||
sheetVc.preferredTransition = .zoom { _ in
|
||||
return sourceView
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#if __has_include(<React/RCTSurfaceTouchHandler.h>)
|
||||
#import <React/RCTSurfaceTouchHandler.h>
|
||||
#endif
|
||||
@@ -1,116 +0,0 @@
|
||||
# expo-scroll-forwarder
|
||||
|
||||
An Expo native module that forwards scroll gestures from a UIView to a UIScrollView on iOS. This enables custom scroll behaviors by allowing a non-scrollable view to control a scrollable view's scroll position.
|
||||
|
||||
## What It Does
|
||||
|
||||
This module solves a specific interaction problem: allowing a fixed header or overlay view to respond to scroll gestures and forward them to an underlying scroll view. The primary use case in the Bluesky app is the profile screen, where the profile header sits above a scrollable content area and can be dragged to scroll the content below it.
|
||||
|
||||
Key behaviors:
|
||||
- Captures pan gestures on a wrapper view and translates them to scroll offsets on a target scroll view
|
||||
- Implements physics-based deceleration animations that match native scroll behavior
|
||||
- Supports pull-to-refresh interactions with haptic feedback
|
||||
- Prevents gesture conflicts with iOS swipe-back navigation by only activating on vertical pans
|
||||
- Provides rubber-band damping when scrolling past content bounds
|
||||
|
||||
## Architecture
|
||||
|
||||
The module consists of three main parts:
|
||||
|
||||
### 1. Native iOS Implementation (Swift)
|
||||
|
||||
**ExpoScrollForwarderView.swift** - The core native view component that:
|
||||
- Attaches a UIPanGestureRecognizer to intercept scroll gestures
|
||||
- Finds and references the target RCTScrollView using its React Native tag
|
||||
- Implements custom scroll physics including velocity-based decay animation
|
||||
- Manages gesture recognizer delegation to prevent conflicts with system gestures
|
||||
- Handles pull-to-refresh activation at -130pt scroll offset with haptic feedback
|
||||
|
||||
**ExpoScrollForwarderModule.swift** - The Expo module definition that:
|
||||
- Registers the view component with Expo
|
||||
- Exposes the `scrollViewTag` prop to specify which scroll view to control
|
||||
|
||||
### 2. TypeScript Interface
|
||||
|
||||
**ExpoScrollForwarderView.tsx** - Platform-specific implementations:
|
||||
- **iOS (.ios.tsx)**: Wraps the native view manager from expo-modules-core
|
||||
- **Default (.tsx)**: No-op wrapper that just renders children (for Android/Web compatibility)
|
||||
|
||||
**ExpoScrollForwarder.types.ts** - TypeScript type definitions:
|
||||
- `scrollViewTag`: The React Native tag of the scroll view to control
|
||||
- `children`: The content to render (typically a header component)
|
||||
|
||||
### 3. Module Configuration
|
||||
|
||||
**expo-module.config.json** - Declares iOS-only platform support
|
||||
|
||||
**ExpoScrollForwarder.podspec** - CocoaPods specification for iOS dependency management
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import {ExpoScrollForwarderView} from 'expo-scroll-forwarder'
|
||||
|
||||
function ProfileScreen() {
|
||||
const scrollViewTag = useRef(null)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ExpoScrollForwarderView scrollViewTag={scrollViewTag.current}>
|
||||
<ProfileHeader />
|
||||
</ExpoScrollForwarderView>
|
||||
|
||||
<ScrollView ref={scrollViewTag}>
|
||||
{/* Scrollable content */}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The `scrollViewTag` prop must be the React Native tag (numeric identifier) of the target scroll view. The module uses this to locate the native UIScrollView instance.
|
||||
|
||||
## Platform Support
|
||||
|
||||
- **iOS**: Full native implementation with custom scroll physics
|
||||
- **Android**: No-op wrapper (renders children without scroll forwarding)
|
||||
- **Web**: No-op wrapper (renders children without scroll forwarding)
|
||||
|
||||
The module is designed to enhance iOS UX while gracefully degrading on other platforms.
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### Gesture Recognition
|
||||
- Only activates when pan velocity is more vertical than horizontal (`abs(velocity.y) > abs(velocity.x)`)
|
||||
- Delegates to UIGestureRecognizerDelegate to prevent simultaneous recognition with navigation swipe-back
|
||||
- Adds tap/long-press recognizers to the scroll view to cancel ongoing animations
|
||||
|
||||
### Scroll Physics
|
||||
- Implements custom decay animation at 120fps using a Timer
|
||||
- Velocity decay factor: 0.9875 per frame
|
||||
- Velocity clamped to +/- 5000 points/second
|
||||
- Rubber-band damping: offsets below 0 are reduced by 55%
|
||||
- Animation stops when velocity drops below 5 points/second
|
||||
|
||||
### Pull-to-Refresh
|
||||
- Triggers at -130pt scroll offset
|
||||
- Provides haptic feedback (UIImpactFeedbackGenerator, light style)
|
||||
- Calls refresh control via `RCTRefreshControl.forwarderBeginRefreshing()`
|
||||
|
||||
### Scroll View Management
|
||||
- Dynamically finds scroll view using `AppContext.findView(withTag:ofType:)`
|
||||
- Properly cleans up gesture recognizers when switching between scroll views
|
||||
- Maintains references to both the scroll view and its refresh control
|
||||
|
||||
## Files Overview
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `ios/ExpoScrollForwarderView.swift` | Native iOS view implementation with gesture handling and scroll physics |
|
||||
| `ios/ExpoScrollForwarderModule.swift` | Expo module registration and prop definitions |
|
||||
| `ios/ExpoScrollForwarder.podspec` | CocoaPods dependency specification |
|
||||
| `src/ExpoScrollForwarderView.ios.tsx` | TypeScript wrapper for iOS native view |
|
||||
| `src/ExpoScrollForwarderView.tsx` | Default no-op implementation for other platforms |
|
||||
| `src/ExpoScrollForwarder.types.ts` | TypeScript type definitions |
|
||||
| `index.ts` | Module entry point |
|
||||
| `expo-module.config.json` | Expo module configuration |
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"ios": {
|
||||
"modules": ["ExpoScrollForwarderModule"]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView'
|
||||
@@ -1,21 +0,0 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ExpoScrollForwarder'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Forward scroll gesture from UIView to UIScrollView'
|
||||
s.description = 'Forward scroll gesture from UIView to UIScrollView'
|
||||
s.author = 'bluesky-social'
|
||||
s.homepage = 'https://github.com/bluesky-social/social-app'
|
||||
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
||||
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,mm,swift,hpp,cpp}"
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoScrollForwarderModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoScrollForwarder")
|
||||
|
||||
View(ExpoScrollForwarderView.self) {
|
||||
Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in
|
||||
view.scrollViewTag = prop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
import React
|
||||
|
||||
// This view will be used as a native component. Make sure to inherit from `ExpoView`
|
||||
// to apply the proper styling (e.g. border radius and shadows).
|
||||
class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
|
||||
var scrollViewTag: Int? {
|
||||
didSet {
|
||||
self.tryFindScrollView()
|
||||
}
|
||||
}
|
||||
|
||||
private var rctScrollView: RCTScrollView?
|
||||
private var rctRefreshCtrl: RCTRefreshControl?
|
||||
private var cancelGestureRecognizers: [UIGestureRecognizer]?
|
||||
private var animTimer: Timer?
|
||||
private var initialOffset: CGFloat = 0.0
|
||||
private var didImpact: Bool = false
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
|
||||
let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:)))
|
||||
pg.delegate = self
|
||||
self.addGestureRecognizer(pg)
|
||||
|
||||
let tg = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
|
||||
tg.isEnabled = false
|
||||
tg.delegate = self
|
||||
|
||||
let lpg = UILongPressGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
|
||||
lpg.minimumPressDuration = 0.01
|
||||
lpg.isEnabled = false
|
||||
lpg.delegate = self
|
||||
|
||||
self.cancelGestureRecognizers = [lpg, tg]
|
||||
}
|
||||
|
||||
// We don't want to recognize the scroll pan gesture and the swipe back gesture together
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// We only want the "scroll" gesture to happen whenever the pan is vertical, otherwise it will
|
||||
// interfere with the native swipe back gesture.
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else {
|
||||
return true
|
||||
}
|
||||
|
||||
let velocity = gestureRecognizer.velocity(in: self)
|
||||
return abs(velocity.y) > abs(velocity.x)
|
||||
}
|
||||
|
||||
// This will be used to cancel the scroll animation whenever we tap inside of the header. We don't need another
|
||||
// recognizer for this one.
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
self.stopTimer()
|
||||
}
|
||||
|
||||
// This will be used to cancel the animation whenever we press inside of the scroll view. We don't want to change
|
||||
// the scroll view gesture's delegate, so we add an additional recognizer to detect this.
|
||||
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) {
|
||||
self.stopTimer()
|
||||
}
|
||||
|
||||
@IBAction func callOnPan(_ sender: UIPanGestureRecognizer) {
|
||||
guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else {
|
||||
return
|
||||
}
|
||||
|
||||
let translation = sender.translation(in: self).y
|
||||
|
||||
if sender.state == .began {
|
||||
if sv.contentOffset.y < 0 {
|
||||
sv.contentOffset.y = 0
|
||||
}
|
||||
|
||||
self.initialOffset = sv.contentOffset.y
|
||||
}
|
||||
|
||||
if sender.state == .changed {
|
||||
sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset)
|
||||
|
||||
if sv.contentOffset.y <= -130, !didImpact {
|
||||
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||
generator.impactOccurred()
|
||||
|
||||
self.didImpact = true
|
||||
}
|
||||
}
|
||||
|
||||
if sender.state == .ended {
|
||||
let velocity = sender.velocity(in: self).y
|
||||
self.didImpact = false
|
||||
|
||||
if sv.contentOffset.y <= -130 {
|
||||
self.rctRefreshCtrl?.forwarderBeginRefreshing()
|
||||
return
|
||||
}
|
||||
|
||||
// A check for a velocity under 250 prevents animations from occurring when they wouldn't in a normal
|
||||
// scroll view
|
||||
if abs(velocity) < 250, sv.contentOffset.y >= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
self.startDecayAnimation(translation, velocity)
|
||||
}
|
||||
}
|
||||
|
||||
func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) {
|
||||
guard let sv = self.rctScrollView?.scrollView else {
|
||||
return
|
||||
}
|
||||
|
||||
var velocity = velocity
|
||||
|
||||
self.enableCancelGestureRecognizers()
|
||||
|
||||
if velocity > 0 {
|
||||
velocity = min(velocity, 5000)
|
||||
} else {
|
||||
velocity = max(velocity, -5000)
|
||||
}
|
||||
|
||||
var animTranslation = -translation
|
||||
self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { _ in
|
||||
velocity *= 0.9875
|
||||
animTranslation = (-velocity / 120) + animTranslation
|
||||
|
||||
let nextOffset = self.dampenOffset(animTranslation + self.initialOffset)
|
||||
|
||||
if nextOffset <= 0 {
|
||||
if self.initialOffset <= 1 {
|
||||
self.scrollToOffset(0)
|
||||
} else {
|
||||
sv.contentOffset.y = 0
|
||||
}
|
||||
|
||||
self.stopTimer()
|
||||
return
|
||||
} else {
|
||||
sv.contentOffset.y = nextOffset
|
||||
}
|
||||
|
||||
if abs(velocity) < 5 {
|
||||
self.stopTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dampenOffset(_ offset: CGFloat) -> CGFloat {
|
||||
if offset < 0 {
|
||||
return offset - (offset * 0.55)
|
||||
}
|
||||
|
||||
return offset
|
||||
}
|
||||
|
||||
func tryFindScrollView() {
|
||||
guard let scrollViewTag = scrollViewTag else {
|
||||
return
|
||||
}
|
||||
|
||||
// Before we switch to a different scrollview, we always want to remove the cancel gesture recognizer.
|
||||
// Otherwise we might end up with duplicates when we switch back to that scrollview.
|
||||
self.removeCancelGestureRecognizers()
|
||||
|
||||
self.rctScrollView = self.appContext?
|
||||
.findView(withTag: scrollViewTag, ofType: RCTScrollView.self)
|
||||
self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl
|
||||
|
||||
self.addCancelGestureRecognizers()
|
||||
}
|
||||
|
||||
func addCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
self.rctScrollView?.scrollView?.addGestureRecognizer(r)
|
||||
}
|
||||
}
|
||||
|
||||
func removeCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
self.rctScrollView?.scrollView?.removeGestureRecognizer(r)
|
||||
}
|
||||
}
|
||||
|
||||
func enableCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
r.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
func disableCancelGestureRecognizers() {
|
||||
self.cancelGestureRecognizers?.forEach { r in
|
||||
r.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
func scrollToOffset(_ offset: Int, animated: Bool = true) {
|
||||
self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated)
|
||||
}
|
||||
|
||||
func stopTimer() {
|
||||
self.disableCancelGestureRecognizers()
|
||||
self.animTimer?.invalidate()
|
||||
self.animTimer = nil
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export interface ExpoScrollForwarderViewProps {
|
||||
scrollViewTag: number | null
|
||||
children: React.ReactNode
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
|
||||
|
||||
const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
|
||||
requireNativeViewManager('ExpoScrollForwarder')
|
||||
|
||||
export function ExpoScrollForwarderView({
|
||||
children,
|
||||
...rest
|
||||
}: ExpoScrollForwarderViewProps) {
|
||||
return <NativeView {...rest}>{children}</NativeView>
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
|
||||
|
||||
export function ExpoScrollForwarderView({
|
||||
children,
|
||||
}: React.PropsWithChildren<ExpoScrollForwarderViewProps>) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
require "json"
|
||||
|
||||
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "ScrollForwarder"
|
||||
s.version = package["version"]
|
||||
s.summary = package["description"]
|
||||
s.homepage = package["homepage"]
|
||||
s.license = package["license"]
|
||||
s.authors = package["author"]
|
||||
|
||||
s.platforms = { :ios => min_ios_version_supported }
|
||||
s.source = { :git => ".git", :tag => "#{s.version}" }
|
||||
|
||||
s.source_files = "ios/**/*.{h,m,mm,cpp}"
|
||||
s.private_header_files = "ios/**/*.h"
|
||||
|
||||
install_modules_dependencies(s)
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
#import <React/RCTViewComponentView.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
#ifndef ScrollForwarderViewNativeComponent_h
|
||||
#define ScrollForwarderViewNativeComponent_h
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface ScrollForwarderView : RCTViewComponentView
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
#endif /* ScrollForwarderViewNativeComponent_h */
|
||||
@@ -0,0 +1,411 @@
|
||||
#import "ScrollForwarderView.h"
|
||||
|
||||
#import <React/RCTEnhancedScrollView.h>
|
||||
#import <React/RCTScrollViewComponentView.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/ComponentDescriptors.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/EventEmitters.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/Props.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/RCTComponentViewHelpers.h>
|
||||
|
||||
#import "RCTFabricComponentsPlugins.h"
|
||||
|
||||
using namespace facebook::react;
|
||||
|
||||
// How far down a pull needs to be to trigger a refresh
|
||||
static const CGFloat kPullThreshold = 130.0;
|
||||
static const CGFloat kDampingFactor = 0.55;
|
||||
// The top speed that free scrolling can have
|
||||
static const CGFloat kMaxVelocity = 5000.0;
|
||||
// Free scrolling decay. This seems to be close to the default iOS value
|
||||
static const CGFloat kVelocityDecay = 0.9875;
|
||||
// What scroll release velocity will actually trigger free scrolling
|
||||
static const CGFloat kMinimumVelocity = 5.0;
|
||||
|
||||
@interface ScrollForwarderView () <RCTScrollForwarderViewViewProtocol, UIGestureRecognizerDelegate>
|
||||
|
||||
@end
|
||||
|
||||
@implementation ScrollForwarderView {
|
||||
NSArray<UIGestureRecognizer *> * _cancelGestureRecognizers;
|
||||
RCTScrollViewComponentView * _svcv;
|
||||
CGPoint _initialOffset;
|
||||
|
||||
CADisplayLink * _displayLink;
|
||||
CGFloat _currentVelocity;
|
||||
CGFloat _accumulatedTranslation;
|
||||
|
||||
bool _didImpact;
|
||||
}
|
||||
|
||||
+ (ComponentDescriptorProvider)componentDescriptorProvider
|
||||
{
|
||||
return concreteComponentDescriptorProvider<ScrollForwarderViewComponentDescriptor>();
|
||||
}
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame
|
||||
{
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
static const auto defaultProps = std::make_shared<const ScrollForwarderViewProps>();
|
||||
_props = defaultProps;
|
||||
|
||||
UIPanGestureRecognizer *pg = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePan:)];
|
||||
pg.delegate = self;
|
||||
pg.cancelsTouchesInView = false;
|
||||
[self addGestureRecognizer:pg];
|
||||
|
||||
UITapGestureRecognizer *tg = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap:)];
|
||||
[tg setEnabled:false];
|
||||
tg.delegate = self;
|
||||
|
||||
UILongPressGestureRecognizer *lpg = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
||||
[lpg setMinimumPressDuration:0.01];
|
||||
[lpg setEnabled:false];
|
||||
lpg.delegate = self;
|
||||
|
||||
NSArray<UIGestureRecognizer *> *cancelGestureRecognizers = [NSArray arrayWithObjects:lpg, tg, nil];
|
||||
_cancelGestureRecognizers = cancelGestureRecognizers;
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[self stopAnimation];
|
||||
[self removeCancelGestureRecognizers];
|
||||
_svcv = nil;
|
||||
|
||||
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
|
||||
gr.delegate = nil;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)prepareForRecycle
|
||||
{
|
||||
[super prepareForRecycle];
|
||||
[self stopAnimation];
|
||||
[self removeCancelGestureRecognizers];
|
||||
_svcv = nil;
|
||||
}
|
||||
|
||||
// MARK: - Props
|
||||
|
||||
- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
|
||||
{
|
||||
const auto &oldViewProps = *std::static_pointer_cast<ScrollForwarderViewProps const>(_props);
|
||||
const auto &newViewProps = *std::static_pointer_cast<ScrollForwarderViewProps const>(props);
|
||||
|
||||
if (oldViewProps.scrollViewTag != newViewProps.scrollViewTag) {
|
||||
[self tryFindScrollView];
|
||||
}
|
||||
|
||||
if (oldViewProps.refreshing != newViewProps.refreshing) {
|
||||
if (!newViewProps.refreshing) {
|
||||
[self endRefreshing];
|
||||
}
|
||||
}
|
||||
|
||||
[super updateProps:props oldProps:oldProps];
|
||||
}
|
||||
|
||||
// MARK: - UIGestureRecognizerDelegate
|
||||
|
||||
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
|
||||
{
|
||||
if ([gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]] && [otherGestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
|
||||
{
|
||||
if (![gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) {
|
||||
return YES;
|
||||
}
|
||||
|
||||
UIPanGestureRecognizer *pg = (UIPanGestureRecognizer *)gestureRecognizer;
|
||||
CGPoint velocity = [pg velocityInView:self];
|
||||
|
||||
return fabs(velocity.y) > fabs(velocity.x);
|
||||
}
|
||||
|
||||
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
|
||||
{
|
||||
[self stopAnimation];
|
||||
[super touchesBegan:touches withEvent:event];
|
||||
}
|
||||
|
||||
// MARK: - Scroll Forwarding
|
||||
|
||||
- (void)removeCancelGestureRecognizers
|
||||
{
|
||||
if (!_svcv) return;
|
||||
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
|
||||
[_svcv.scrollView removeGestureRecognizer:gr];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)addCancelGestureRecognizers
|
||||
{
|
||||
if (!_svcv) return;
|
||||
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
|
||||
[_svcv.scrollView addGestureRecognizer:gr];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)enableCancelGestureRecognizers
|
||||
{
|
||||
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
|
||||
[gr setEnabled:true];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)disableCancelGestureRecognizers
|
||||
{
|
||||
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
|
||||
[gr setEnabled:false];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)scrollToOffset:(CGPoint)offset animated:(bool)animated
|
||||
{
|
||||
if (!_svcv) return;
|
||||
[_svcv scrollToOffset:offset animated:animated];
|
||||
}
|
||||
|
||||
- (void)stopAnimation
|
||||
{
|
||||
[self disableCancelGestureRecognizers];
|
||||
[_displayLink invalidate];
|
||||
_displayLink = nil;
|
||||
}
|
||||
|
||||
- (void)handlePan:(UIPanGestureRecognizer *)gesture {
|
||||
if (!_svcv) return;
|
||||
|
||||
UIScrollView *sv = _svcv.scrollView;
|
||||
|
||||
CGPoint translation = [gesture translationInView:self];
|
||||
|
||||
if (gesture.state == UIGestureRecognizerStateBegan) {
|
||||
_didImpact = false;
|
||||
|
||||
if (sv.contentOffset.y < 0) {
|
||||
CGPoint newOffset = CGPointMake(sv.contentOffset.x, 0);
|
||||
sv.contentOffset = newOffset;
|
||||
}
|
||||
|
||||
_initialOffset = sv.contentOffset;
|
||||
}
|
||||
|
||||
if (gesture.state == UIGestureRecognizerStateChanged) {
|
||||
CGPoint newOffset = CGPointMake(sv.contentOffset.x, [self dampenOffset:(-translation.y + _initialOffset.y)]);
|
||||
sv.contentOffset = newOffset;
|
||||
|
||||
if (sv.contentOffset.y <= -kPullThreshold && !_didImpact) {
|
||||
UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
||||
[generator impactOccurred];
|
||||
_didImpact = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (gesture.state == UIGestureRecognizerStateEnded) {
|
||||
CGPoint velocity = [gesture velocityInView:self];
|
||||
|
||||
if (sv.contentOffset.y <= -kPullThreshold) {
|
||||
[self refresh];
|
||||
return;
|
||||
}
|
||||
|
||||
if (sv.contentOffset.y < 0) {
|
||||
CGPoint newOffset = CGPointMake(sv.contentOffset.x, 0);
|
||||
[self scrollToOffset:newOffset animated:true];
|
||||
return;
|
||||
}
|
||||
|
||||
if (abs(velocity.y) < 250 && sv.contentOffset.y >= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
[self startDecayWithInitialTranslation:translation.y velocity:velocity.y];
|
||||
}
|
||||
}
|
||||
|
||||
- (CGFloat)dampenOffset:(CGFloat)offset
|
||||
{
|
||||
if (offset < 0) {
|
||||
return offset - (offset * kDampingFactor);
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
- (void)handleTap:(UITapGestureRecognizer *)gesture {
|
||||
[self stopAnimation];
|
||||
}
|
||||
|
||||
- (void)handleLongPress:(UILongPressGestureRecognizer *)gesture {
|
||||
[self stopAnimation];
|
||||
}
|
||||
|
||||
- (void)startDecayWithInitialTranslation:(CGFloat)translation velocity:(CGFloat)startVelocity
|
||||
{
|
||||
if (!_svcv) return;
|
||||
|
||||
startVelocity = MAX(-kMaxVelocity, MIN(kMaxVelocity, startVelocity));
|
||||
_currentVelocity = startVelocity;
|
||||
_accumulatedTranslation = -translation;
|
||||
|
||||
[self enableCancelGestureRecognizers];
|
||||
|
||||
[_displayLink invalidate];
|
||||
|
||||
CADisplayLink *link = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDecayStep:)];
|
||||
|
||||
link.preferredFramesPerSecond = 60;
|
||||
[link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
|
||||
_displayLink = link;
|
||||
}
|
||||
|
||||
- (void)handleDecayStep:(CADisplayLink *)link
|
||||
{
|
||||
_currentVelocity *= kVelocityDecay;
|
||||
|
||||
CGFloat delta = -_currentVelocity / link.preferredFramesPerSecond;
|
||||
_accumulatedTranslation += delta;
|
||||
|
||||
CGFloat rawY = _accumulatedTranslation + _initialOffset.y;
|
||||
CGFloat nextY = rawY > 0 ? rawY : 0;
|
||||
|
||||
CGPoint newOffset = CGPointMake(
|
||||
_svcv.scrollView.contentOffset.x,
|
||||
nextY
|
||||
);
|
||||
_svcv.scrollView.contentOffset = newOffset;
|
||||
|
||||
if (fabs(_currentVelocity) < kMinimumVelocity || nextY <= 0) {
|
||||
[link invalidate];
|
||||
_displayLink = nil;
|
||||
[self disableCancelGestureRecognizers];
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* We use this component on profile pages. The screne consists of a header component, a scrollview with buttons to
|
||||
* switch between profile tabs, and a pager view (RNCPagerViewComponentView). Both the header and the tab bar are
|
||||
* inside the same RCTViewComponentView. The view heirarchy looks something like this:
|
||||
* - RCTViewComponentView
|
||||
* -- RNCPagerViewComponentView
|
||||
* ----- (Many views deep) RCTScrollViewComponentView
|
||||
* ------ RCTEnhancedScrollView
|
||||
* -- RCTViewComponentView
|
||||
* --- RCTViewComponentView
|
||||
* ---- ScrollForwarderView
|
||||
* --- RCTScrollViewComponentView
|
||||
* ---- RCTEnhancedScrollView
|
||||
*
|
||||
* We want to find that RCTScrollViewComponentView inside of the RNCPagerViewComponentView. To achieve this, we can
|
||||
* use self.superview.superview.superview to get to the root RCTViewComponentView, find the RNCPagerViewComponentView,
|
||||
* then iterate through that view's subviews until we find a RCTScrollViewComponentView.
|
||||
*
|
||||
* This isn't great, because if we reorder the React components, we'll need to update this logic. There's probably
|
||||
* an easier way to achieve this, similar to how we used to do it in Paper (ie, get the scroll view's tag and find that),
|
||||
* but this also comes with some benefits, eg being able to reduce a lot of the logic in the JS code and just find the
|
||||
* scrollview when subviews change.
|
||||
*/
|
||||
- (void)tryFindScrollView
|
||||
{
|
||||
[self removeCancelGestureRecognizers];
|
||||
|
||||
// The root RCTViewComponentView
|
||||
UIView *rootView = self.superview.superview.superview;
|
||||
UIView *pagerView;
|
||||
|
||||
NSString *targetClsName = @"RNCPagerViewComponentView";
|
||||
Class targetCls = NSClassFromString(targetClsName);
|
||||
|
||||
for (UIView *subview in rootView.subviews) {
|
||||
if ([subview isKindOfClass:targetCls]) {
|
||||
pagerView = subview;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pagerView) return;
|
||||
|
||||
RCTScrollViewComponentView *svcv = [self findRTCScrollViewComponentViewInView:pagerView];
|
||||
|
||||
if (!svcv) return;
|
||||
|
||||
_svcv = svcv;
|
||||
[self addCancelGestureRecognizers];
|
||||
}
|
||||
|
||||
- (RCTScrollViewComponentView *)findRTCScrollViewComponentViewInView:(UIView *)view
|
||||
{
|
||||
for (UIView *subview in view.subviews) {
|
||||
if ([subview isKindOfClass:[RCTScrollViewComponentView class]]) {
|
||||
RCTScrollViewComponentView *svcv = (RCTScrollViewComponentView *) subview;
|
||||
return svcv;
|
||||
}
|
||||
|
||||
RCTScrollViewComponentView *svcv = [self findRTCScrollViewComponentViewInView:subview];
|
||||
if (svcv) return svcv;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (UIRefreshControl *)refreshContorl
|
||||
{
|
||||
if (!_svcv) return nil;
|
||||
return _svcv.scrollView.refreshControl;
|
||||
}
|
||||
|
||||
- (void)refresh
|
||||
{
|
||||
__weak ScrollForwarderView *weakSelf = self;
|
||||
|
||||
[_svcv.scrollView.refreshControl beginRefreshing];
|
||||
|
||||
[UIView animateWithDuration:0.3
|
||||
delay:0
|
||||
options:UIViewAnimationOptionBeginFromCurrentState
|
||||
animations:^(void) {
|
||||
if (!weakSelf) return;
|
||||
|
||||
__strong ScrollForwarderView *self = weakSelf;
|
||||
|
||||
// Whenever we call this method, the scrollview will always be at a position of
|
||||
// -130 or less. Scrolling back to -80 simulates the default behavior of RCTRefreshControl
|
||||
[self->_svcv.scrollView setContentOffset:CGPointMake(0, -65)];
|
||||
}
|
||||
completion:^(__unused BOOL finished) {
|
||||
__strong ScrollForwarderView *self = weakSelf;
|
||||
|
||||
if (self->_eventEmitter != nullptr) {
|
||||
std::dynamic_pointer_cast<const facebook::react::ScrollForwarderViewEventEmitter>(self->_eventEmitter)
|
||||
->onRefresh(facebook::react::ScrollForwarderViewEventEmitter::OnRefresh{});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
- (void)endRefreshing
|
||||
{
|
||||
UIRefreshControl *rc = [self refreshContorl];
|
||||
|
||||
CGPoint newOffset = CGPointMake(_svcv.scrollView.contentOffset.x, 0.0);
|
||||
[self scrollToOffset:newOffset animated:true];
|
||||
|
||||
[rc endRefreshing];
|
||||
}
|
||||
|
||||
Class<RCTComponentViewProtocol> ScrollForwarderViewCls(void)
|
||||
{
|
||||
return ScrollForwarderView.class;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "react-native-scroll-forwarder",
|
||||
"version": "0.0.0",
|
||||
"description": "Scroll forwarder module for Bluesky profile headers",
|
||||
"main": "src/index",
|
||||
"codegenConfig": {
|
||||
"name": "ScrollForwarderViewSpec",
|
||||
"type": "all",
|
||||
"jsSrcsDir": "src",
|
||||
"ios": {
|
||||
"componentProvider": {
|
||||
"ScrollForwarderView": "ScrollForwarderView"
|
||||
}
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": "*"
|
||||
},
|
||||
"author": "Hailey <me@haileyok.com>",
|
||||
"license": "MIT",
|
||||
"homepage": "#readme",
|
||||
"create-react-native-library": {
|
||||
"languages": "kotlin-objc",
|
||||
"type": "fabric-view",
|
||||
"version": "0.50.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
default as NativeScrollForwarderView,
|
||||
type NativeProps,
|
||||
} from './ScrollForwarderViewNativeComponent'
|
||||
|
||||
export function ScrollForwarderView({children, ...rest}: NativeProps) {
|
||||
return (
|
||||
<NativeScrollForwarderView {...rest} style={{flex: 1}}>
|
||||
{children}
|
||||
</NativeScrollForwarderView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {type NativeProps} from './ScrollForwarderViewNativeComponent'
|
||||
|
||||
export function ScrollForwarderView({children}: NativeProps) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
codegenNativeComponent,
|
||||
type CodegenTypes,
|
||||
type ViewProps,
|
||||
} from 'react-native'
|
||||
|
||||
type OnRefreshEvent = {}
|
||||
|
||||
export interface NativeProps extends ViewProps {
|
||||
scrollViewTag: CodegenTypes.Int32 | null
|
||||
refreshing?: boolean
|
||||
onRefresh?: CodegenTypes.BubblingEventHandler<OnRefreshEvent>
|
||||
}
|
||||
|
||||
export default codegenNativeComponent<NativeProps>('ScrollForwarderView')
|
||||
@@ -0,0 +1,2 @@
|
||||
export {ScrollForwarderView} from './ScrollForwarderView'
|
||||
export * from './ScrollForwarderViewNativeComponent'
|
||||
+54
-54
@@ -30,7 +30,6 @@
|
||||
},
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react-native-reanimated",
|
||||
"@sentry/react-native",
|
||||
"react-native-pager-view"
|
||||
]
|
||||
@@ -40,7 +39,7 @@
|
||||
"prepare": "is-ci || husky",
|
||||
"postinstall": "pnpm intl:compile-if-needed",
|
||||
"prebuild": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean",
|
||||
"android": "expo run:android",
|
||||
"android": "expo run:android --variant debugOptimized",
|
||||
"android:prod": "expo run:android --variant release",
|
||||
"android:profile": "BSKY_PROFILE=1 expo run:android --variant release",
|
||||
"ios": "expo run:ios",
|
||||
@@ -101,7 +100,6 @@
|
||||
"@bsky.app/expo-image-crop-tool": "^0.5.1",
|
||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||
"@bsky.app/sift": "^0.3.8",
|
||||
"@bsky.app/tapper": "^0.5.7",
|
||||
"@bsky.app/video": "0.3.6",
|
||||
@@ -109,6 +107,7 @@
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@expo/html-elements": "^0.12.5",
|
||||
"@expo/ui": "^56.0.14",
|
||||
"@expo/webpack-config": "^19.0.1",
|
||||
"@floating-ui/dom": "^1.6.3",
|
||||
"@floating-ui/react-dom": "^2.0.8",
|
||||
@@ -155,41 +154,41 @@
|
||||
"emoji-mart": "^5.6.0",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "54.0.34",
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-contacts": "^15.0.10",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-glass-effect": "55.0.8",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-keep-awake": "~15.0.8",
|
||||
"expo-linear-gradient": "~15.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-location": "~19.0.8",
|
||||
"expo-media-library": "~18.2.1",
|
||||
"expo-notifications": "~0.32.17",
|
||||
"expo": "56.0.5",
|
||||
"expo-application": "~56.0.3",
|
||||
"expo-blur": "~56.0.3",
|
||||
"expo-build-properties": "~56.0.15",
|
||||
"expo-camera": "~56.0.7",
|
||||
"expo-clipboard": "~56.0.3",
|
||||
"expo-contacts": "^56.0.7",
|
||||
"expo-dev-client": "~56.0.16",
|
||||
"expo-device": "~56.0.4",
|
||||
"expo-file-system": "~56.0.7",
|
||||
"expo-font": "~56.0.5",
|
||||
"expo-glass-effect": "56.0.4",
|
||||
"expo-haptics": "~56.0.3",
|
||||
"expo-image": "~56.0.9",
|
||||
"expo-image-manipulator": "~56.0.15",
|
||||
"expo-image-picker": "~56.0.14",
|
||||
"expo-intent-launcher": "~56.0.4",
|
||||
"expo-keep-awake": "~56.0.3",
|
||||
"expo-linear-gradient": "~56.0.4",
|
||||
"expo-linking": "~56.0.12",
|
||||
"expo-localization": "~56.0.6",
|
||||
"expo-location": "~56.0.14",
|
||||
"expo-media-library": "~56.0.6",
|
||||
"expo-notifications": "~56.0.14",
|
||||
"expo-paste-input": "^0.2.1",
|
||||
"expo-privacy-sensitive": "^0.1.0",
|
||||
"expo-screen-orientation": "~9.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-sms": "^14.0.7",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-updates": "~29.0.17",
|
||||
"expo-video": "~3.0.16",
|
||||
"expo-video-thumbnails": "^10.0.8",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"expo-screen-orientation": "~56.0.5",
|
||||
"expo-sharing": "~56.0.14",
|
||||
"expo-sms": "^56.0.3",
|
||||
"expo-splash-screen": "~56.0.10",
|
||||
"expo-system-ui": "~56.0.5",
|
||||
"expo-updates": "~56.0.17",
|
||||
"expo-video": "~56.1.2",
|
||||
"expo-video-thumbnails": "^56.0.3",
|
||||
"expo-web-browser": "~56.0.5",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"fuse.js": "^7.1.0",
|
||||
@@ -208,34 +207,36 @@
|
||||
"normalize-url": "^8.0.0",
|
||||
"psl": "1.9.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.1.0",
|
||||
"react": "19.2.3",
|
||||
"react-compiler-runtime": "19.1.0-rc.3",
|
||||
"react-dom": "19.1.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hotkeys-hook": "5.2.4",
|
||||
"react-image-crop": "^11.0.7",
|
||||
"react-is": "19",
|
||||
"react-keyed-flatten-children": "^5.0.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-compressor": "1.13.0",
|
||||
"react-native-date-picker": "^5.0.13",
|
||||
"react-native-device-attest": "^0.1.6",
|
||||
"react-native-drawer-layout": "^4.2.3",
|
||||
"react-native-edge-to-edge": "^1.8.1",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "^1.21.8",
|
||||
"react-native-gesture-handler": "~2.31.2",
|
||||
"react-native-keyboard-controller": "^1.21.6",
|
||||
"react-native-mmkv": "^3.3.3",
|
||||
"react-native-pager-view": "6.8.0",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
"react-native-qrcode-styled": "^0.3.3",
|
||||
"react-native-reanimated": "3.19.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "4.24.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-uitextview": "^1.4.0",
|
||||
"react-native-reanimated": "4.4.0",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-scroll-forwarder": "link:./modules/react-native-scroll-forwarder",
|
||||
"react-native-svg": "15.15.4",
|
||||
"react-native-uitextview": "^2.2.0",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-view-shot": "^4.0.3",
|
||||
"react-native-view-shot": "^5.1.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-web-webview": "^1.0.2",
|
||||
"react-native-webview": "^13.15.0",
|
||||
"react-native-webview": "^13.16.1",
|
||||
"react-native-worklets": "^0.9.1",
|
||||
"react-remove-scroll-bar": "^2.3.8",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
@@ -267,13 +268,13 @@
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/lodash.shuffle": "^4.2.7",
|
||||
"@types/psl": "1.1.1",
|
||||
"@types/react": "^19.1.17",
|
||||
"@types/react-dom": "^19.1.11",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260428.1",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"babel-preset-expo": "~54.0.10",
|
||||
"babel-preset-expo": "~56.0.13",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-bsky-internal": "link:eslint",
|
||||
@@ -290,11 +291,10 @@
|
||||
"husky": "^9.1.7",
|
||||
"is-ci": "^3.0.1",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "~54.0.17",
|
||||
"jest-expo": "~56.0.4",
|
||||
"jest-junit": "^16.0.0",
|
||||
"lint-staged": "^13.2.3",
|
||||
"prettier": "^3.8.3",
|
||||
"react-native-dotenv": "^3.4.11",
|
||||
"react-refresh": "^0.14.0",
|
||||
"svgo": "^3.3.2",
|
||||
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
diff --git a/ios/RNDatePicker.h b/ios/RNDatePicker.h
|
||||
index 480746eb7acfbe86f67547d9e1de7a5be4d5faf2..13d30cb547195993dfcb4005cc0d248de9ac391a 100644
|
||||
--- a/ios/RNDatePicker.h
|
||||
+++ b/ios/RNDatePicker.h
|
||||
@@ -15,6 +15,7 @@ NS_ASSUME_NONNULL_END
|
||||
#else
|
||||
#import "DatePicker.h"
|
||||
#import <UIKit/UIKit.h>
|
||||
+#include <string>
|
||||
|
||||
@interface RNDatePicker : DatePicker
|
||||
|
||||
@@ -22,4 +23,3 @@ NS_ASSUME_NONNULL_END
|
||||
@end
|
||||
|
||||
#endif
|
||||
-
|
||||
@@ -1,48 +0,0 @@
|
||||
diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
|
||||
index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1e0bdd5b1d3b1eceb47bfb0050da84061fd7f77c 100644
|
||||
--- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
|
||||
+++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
-import { Platform } from "react-native";
|
||||
import { scrollTo, useAnimatedReaction } from "react-native-reanimated";
|
||||
|
||||
-import { IS_FABRIC } from "../../../architecture";
|
||||
import { isScrollAtEnd, shouldShiftContent } from "../useChatKeyboard/helpers";
|
||||
|
||||
import type { KeyboardLiftBehavior } from "../useChatKeyboard/types";
|
||||
@@ -52,7 +50,6 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
|
||||
scroll,
|
||||
layout,
|
||||
size,
|
||||
- contentOffsetY,
|
||||
inverted,
|
||||
keyboardLiftBehavior,
|
||||
freeze,
|
||||
@@ -62,20 +59,14 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
|
||||
(target: number) => {
|
||||
"worklet";
|
||||
|
||||
- if (contentOffsetY && IS_FABRIC) {
|
||||
- // eslint-disable-next-line react-compiler/react-compiler
|
||||
- contentOffsetY.value = target;
|
||||
- } else if (Platform.OS === "android") {
|
||||
- // Defer scrollTo so the animatedProps inset commit lands first;
|
||||
- // otherwise the native ScrollView clamps to the old range.
|
||||
- requestAnimationFrame(() => {
|
||||
- scrollTo(scrollViewRef, 0, target, false);
|
||||
- });
|
||||
- } else {
|
||||
+ // Always defer scrollTo so the animatedProps inset commit lands first;
|
||||
+ // otherwise the native ScrollView clamps contentOffset to the old
|
||||
+ // contentInset range (iOS Fabric) or the old contentInsetBottom (Android).
|
||||
+ requestAnimationFrame(() => {
|
||||
scrollTo(scrollViewRef, 0, target, false);
|
||||
- }
|
||||
+ });
|
||||
},
|
||||
- [scrollViewRef, contentOffsetY],
|
||||
+ [scrollViewRef],
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
@@ -1,28 +0,0 @@
|
||||
diff --git a/src/index.js b/src/index.js
|
||||
index fa76d7e1272e7fbe4bbd153104db127f1f6eecad..018b6860b7fa02d498d73b5fd06028bae99abedb 100644
|
||||
--- a/src/index.js
|
||||
+++ b/src/index.js
|
||||
@@ -125,13 +125,17 @@ export function captureRef<T: React$ElementType>(
|
||||
}
|
||||
}
|
||||
if (typeof view !== "number") {
|
||||
- const node = findNodeHandle(view);
|
||||
- if (!node) {
|
||||
- return Promise.reject(
|
||||
- new Error("findNodeHandle failed to resolve view=" + String(view))
|
||||
- );
|
||||
+ if (Platform.OS == 'web') {
|
||||
+ view = view;
|
||||
+ } else {
|
||||
+ const node = findNodeHandle(view);
|
||||
+ if (!node) {
|
||||
+ return Promise.reject(
|
||||
+ new Error("findNodeHandle failed to resolve view=" + String(view))
|
||||
+ );
|
||||
+ }
|
||||
+ view = node;
|
||||
}
|
||||
- view = node;
|
||||
}
|
||||
const { options, errors } = validateOptions(optionsObject);
|
||||
if (__DEV__ && errors.length > 0) {
|
||||
@@ -1,3 +0,0 @@
|
||||
## react-native-view-shot patch
|
||||
|
||||
Temporary patch for web, where `view`'s type has changed.
|
||||
Generated
+2299
-2126
File diff suppressed because it is too large
Load Diff
+10
-19
@@ -4,41 +4,32 @@ strictPeerDependencies: false # default: false
|
||||
trustPolicy: 'no-downgrade' # default: off
|
||||
trustPolicyIgnoreAfter: 10080 # 7 days, default: undefined
|
||||
overrides:
|
||||
'@react-native/babel-preset': '0.81.5'
|
||||
'@react-native/normalize-colors': '0.81.5'
|
||||
'@react-native/babel-preset': '0.85.3'
|
||||
'@react-native/normalize-colors': '0.85.3'
|
||||
'@expo/image-utils': '0.8.12'
|
||||
'@types/estree': '1.0.6'
|
||||
'react-native-compressor': '1.13.0'
|
||||
'react-native-reanimated': '3.19.1'
|
||||
'psl': '1.9.0'
|
||||
'@types/psl': '1.1.1'
|
||||
'react-native-screens': '4.24.0'
|
||||
allowBuilds:
|
||||
'@sentry/cli': true
|
||||
'core-js-pure': true
|
||||
'esbuild': true
|
||||
'msgpackr-extract': true
|
||||
'unrs-resolver': true
|
||||
patchedDependencies:
|
||||
'@discord/bottom-sheet@4.6.1': patches/@discord__bottom-sheet@4.6.1.patch
|
||||
'@sentry/react-native@6.20.0': patches/@sentry__react-native@6.20.0.patch
|
||||
'expo-glass-effect@55.0.8': patches/expo-glass-effect@55.0.8.patch
|
||||
'expo-haptics@15.0.8': patches/expo-haptics@15.0.8.patch
|
||||
'expo-image-picker@17.0.11': patches/expo-image-picker@17.0.11.patch
|
||||
'expo-image@3.0.11': patches/expo-image@3.0.11.patch
|
||||
'expo-media-library@18.2.1': patches/expo-media-library@18.2.1.patch
|
||||
'expo-modules-core@3.0.30': patches/expo-modules-core@3.0.30.patch
|
||||
'expo-notifications@0.32.17': patches/expo-notifications@0.32.17.patch
|
||||
'expo-updates@29.0.17': patches/expo-updates@29.0.17.patch
|
||||
# 'expo-haptics@15.0.8': patches/expo-haptics@15.0.8.patch
|
||||
# 'expo-image-picker@17.0.11': patches/expo-image-picker@17.0.11.patch
|
||||
# 'expo-media-library@18.2.1': patches/expo-media-library@18.2.1.patch
|
||||
# 'expo-modules-core@3.0.30': patches/expo-modules-core@3.0.30.patch
|
||||
# 'expo-notifications@0.32.17': patches/expo-notifications@0.32.17.patch
|
||||
# 'expo-updates@29.0.17': patches/expo-updates@29.0.17.patch
|
||||
'react-native-compressor@1.13.0': patches/react-native-compressor@1.13.0.patch
|
||||
'react-native-date-picker@5.0.13': patches/react-native-date-picker@5.0.13.patch
|
||||
'react-native-drawer-layout@4.2.3': patches/react-native-drawer-layout@4.2.3.patch
|
||||
'react-native-keyboard-controller@1.21.8': patches/react-native-keyboard-controller@1.21.8.patch
|
||||
'react-native-pager-view@6.8.0': patches/react-native-pager-view@6.8.0.patch
|
||||
'react-native-reanimated@3.19.1': patches/react-native-reanimated@3.19.1.patch
|
||||
'react-native-svg@15.12.1': patches/react-native-svg@15.12.1.patch
|
||||
'react-native-uitextview@1.4.0': patches/react-native-uitextview@1.4.0.patch
|
||||
'react-native-view-shot@4.0.3': patches/react-native-view-shot@4.0.3.patch
|
||||
'react-native@0.81.5': patches/react-native@0.81.5.patch
|
||||
'react-native-svg@15.15.4': patches/react-native-svg@15.15.4.patch
|
||||
'sonner-native@0.21.0': patches/sonner-native@0.21.0.patch
|
||||
minimumReleaseAgeExclude:
|
||||
- '@atproto/*'
|
||||
|
||||
+8
-9
@@ -2,24 +2,24 @@ import {forwardRef, useCallback, useEffect, useState} from 'react'
|
||||
import {
|
||||
AccessibilityInfo,
|
||||
Image as RNImage,
|
||||
StyleSheet,
|
||||
useColorScheme,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import Svg, {Path, type SvgProps} from 'react-native-svg'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {Image} from 'expo-image'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
import {atoms as a} from '#/alf'
|
||||
// @ts-ignore
|
||||
import splashImagePointer from '../assets/splash/splash.png'
|
||||
// @ts-ignore
|
||||
@@ -151,9 +151,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
withTiming(
|
||||
1,
|
||||
{duration: 1200, easing: Easing.in(Easing.cubic)},
|
||||
() => {
|
||||
runOnJS(onFinish)()
|
||||
},
|
||||
() => scheduleOnRN(onFinish),
|
||||
),
|
||||
)
|
||||
outroApp.set(() =>
|
||||
@@ -177,7 +175,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
}, [onFinish, intro, outroLogo, outroApp, outroAppOpacity, isReady])
|
||||
|
||||
useEffect(() => {
|
||||
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion)
|
||||
void AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion)
|
||||
}, [])
|
||||
|
||||
const logoAnimations =
|
||||
@@ -188,12 +186,12 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
return (
|
||||
<View style={{flex: 1}} onLayout={onLayout}>
|
||||
{!isAnimationComplete && (
|
||||
<View style={StyleSheet.absoluteFillObject}>
|
||||
<View style={[a.absolute, a.inset_0]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
onLoadEnd={onLoadEnd}
|
||||
source={{uri: isDarkMode ? darkSplashImageUri : splashImageUri}}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
|
||||
<Animated.View
|
||||
@@ -223,7 +221,8 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
{!isAnimationComplete && (
|
||||
<Animated.View
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
logoWrapperAnimation,
|
||||
{
|
||||
flex: 1,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {MMKV} from '@bsky.app/react-native-mmkv'
|
||||
import {MMKV} from 'react-native-mmkv'
|
||||
import {setPolyfills} from '@growthbook/growthbook'
|
||||
import {GrowthBook} from '@growthbook/growthbook-react'
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import {useCallback, useImperativeHandle, useState} from 'react'
|
||||
import {Keyboard} from 'react-native'
|
||||
import DatePicker from 'react-native-date-picker'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useTheme} from '#/alf'
|
||||
// import {useLingui} from '@lingui/react'
|
||||
// import {useTheme} from '#/alf'
|
||||
import {type DateFieldProps} from '#/components/forms/DateField/types'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
// import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {DateFieldButton} from './index.shared'
|
||||
|
||||
@@ -15,26 +14,26 @@ export const LabelText = TextField.LabelText
|
||||
export function DateField({
|
||||
value,
|
||||
inputRef,
|
||||
onChangeDate,
|
||||
// onChangeDate,
|
||||
label,
|
||||
isInvalid,
|
||||
testID,
|
||||
// testID,
|
||||
accessibilityHint,
|
||||
maximumDate,
|
||||
// maximumDate,
|
||||
}: DateFieldProps) {
|
||||
const {i18n} = useLingui()
|
||||
const t = useTheme()
|
||||
// const {i18n} = useLingui()
|
||||
// const t = useTheme()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const onChangeInternal = useCallback(
|
||||
(date: Date) => {
|
||||
setOpen(false)
|
||||
// const onChangeInternal = useCallback(
|
||||
// (date: Date) => {
|
||||
// setOpen(false)
|
||||
|
||||
const formatted = toSimpleDateString(date)
|
||||
onChangeDate(formatted)
|
||||
},
|
||||
[onChangeDate, setOpen],
|
||||
)
|
||||
// const formatted = toSimpleDateString(date)
|
||||
// onChangeDate(formatted)
|
||||
// },
|
||||
// [onChangeDate, setOpen],
|
||||
// )
|
||||
|
||||
useImperativeHandle(
|
||||
inputRef,
|
||||
@@ -54,9 +53,9 @@ export function DateField({
|
||||
setOpen(true)
|
||||
}, [])
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
// const onCancel = useCallback(() => {
|
||||
// setOpen(false)
|
||||
// }, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -68,31 +67,36 @@ export function DateField({
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
|
||||
{open && (
|
||||
// Android implementation of DatePicker currently does not change default button colors according to theme and only takes hex values for buttonColor
|
||||
// Can remove the buttonColor setting if/when this PR is merged: https://github.com/henninghall/react-native-date-picker/pull/871
|
||||
<DatePicker
|
||||
modal
|
||||
open
|
||||
timeZoneOffsetInMinutes={0}
|
||||
theme={t.scheme}
|
||||
// @ts-ignore TODO
|
||||
buttonColor={t.name === 'light' ? '#000000' : '#ffffff'}
|
||||
date={new Date(value)}
|
||||
onConfirm={onChangeInternal}
|
||||
onCancel={onCancel}
|
||||
mode="date"
|
||||
locale={i18n.locale}
|
||||
is24hourSource="locale"
|
||||
testID={`${testID}-datepicker`}
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={accessibilityHint}
|
||||
maximumDate={
|
||||
maximumDate ? new Date(toSimpleDateString(maximumDate)) : undefined
|
||||
{
|
||||
open &&
|
||||
// Android implementation of DatePicker currently does not change default button colors according to theme and only takes hex values for buttonColor
|
||||
// Can remove the buttonColor setting if/when this PR is merged: https://github.com/henninghall/react-native-date-picker/pull/871
|
||||
|
||||
{
|
||||
/* TODO: replace with expo ui date picker */
|
||||
}
|
||||
/>
|
||||
)}
|
||||
// <DatePicker
|
||||
// modal
|
||||
// open
|
||||
// timeZoneOffsetInMinutes={0}
|
||||
// theme={t.scheme}
|
||||
// // @ts-ignore TODO
|
||||
// buttonColor={t.name === 'light' ? '#000000' : '#ffffff'}
|
||||
// date={new Date(value)}
|
||||
// onConfirm={onChangeInternal}
|
||||
// onCancel={onCancel}
|
||||
// mode="date"
|
||||
// locale={i18n.locale}
|
||||
// is24hourSource="locale"
|
||||
// testID={`${testID}-datepicker`}
|
||||
// aria-label={label}
|
||||
// accessibilityLabel={label}
|
||||
// accessibilityHint={accessibilityHint}
|
||||
// maximumDate={
|
||||
// maximumDate ? new Date(toSimpleDateString(maximumDate)) : undefined
|
||||
// }
|
||||
// />
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import {useCallback, useImperativeHandle} from 'react'
|
||||
import {useImperativeHandle} from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import DatePicker from 'react-native-date-picker'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {type DateFieldProps} from '#/components/forms/DateField/types'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
// import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {DateFieldButton} from './index.shared'
|
||||
|
||||
@@ -27,26 +26,26 @@ export const LabelText = TextField.LabelText
|
||||
export function DateField({
|
||||
value,
|
||||
inputRef,
|
||||
onChangeDate,
|
||||
// onChangeDate,
|
||||
testID,
|
||||
label,
|
||||
isInvalid,
|
||||
accessibilityHint,
|
||||
maximumDate,
|
||||
// maximumDate,
|
||||
}: DateFieldProps) {
|
||||
const {_, i18n} = useLingui()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
// const t = useTheme()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
const onChangeInternal = useCallback(
|
||||
(date: Date | undefined) => {
|
||||
if (date) {
|
||||
const formatted = toSimpleDateString(date)
|
||||
onChangeDate(formatted)
|
||||
}
|
||||
},
|
||||
[onChangeDate],
|
||||
)
|
||||
// const onChangeInternal = useCallback(
|
||||
// (date: Date | undefined) => {
|
||||
// if (date) {
|
||||
// const formatted = toSimpleDateString(date)
|
||||
// onChangeDate(formatted)
|
||||
// }
|
||||
// },
|
||||
// [onChangeDate],
|
||||
// )
|
||||
|
||||
useImperativeHandle(
|
||||
inputRef,
|
||||
@@ -82,7 +81,8 @@ export function DateField({
|
||||
<Dialog.ScrollableInner label={label}>
|
||||
<View style={a.gap_lg}>
|
||||
<View style={[a.relative, a.w_full, a.align_center]}>
|
||||
<DatePicker
|
||||
{/* TODO: replace with expo ui date picker */}
|
||||
{/*<DatePicker
|
||||
timeZoneOffsetInMinutes={0}
|
||||
theme={t.scheme}
|
||||
date={new Date(toSimpleDateString(value))}
|
||||
@@ -98,7 +98,7 @@ export function DateField({
|
||||
? new Date(toSimpleDateString(maximumDate))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
/>*/}
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Done`)}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {mergeRefs} from '#/lib/merge-refs'
|
||||
import {
|
||||
android,
|
||||
applyFonts,
|
||||
atoms as a,
|
||||
platform,
|
||||
@@ -219,10 +218,6 @@ export function createInput(Component: typeof TextInput) {
|
||||
paddingTop: 13,
|
||||
paddingBottom: 13,
|
||||
},
|
||||
android({
|
||||
paddingTop: 8,
|
||||
paddingBottom: 9,
|
||||
}),
|
||||
/*
|
||||
* Margins are needed here to avoid autofill background overlapping the
|
||||
* top and bottom borders - esb
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
|
||||
|
||||
jest.mock('@bsky.app/react-native-mmkv', () => ({
|
||||
jest.mock('react-native-mmkv', () => ({
|
||||
MMKV: class MMKVMock {
|
||||
_store = new Map<string, string>()
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {IS_ANDROID, IS_IOS, IS_TESTFLIGHT} from '#/env'
|
||||
import {IS_IOS, IS_TESTFLIGHT} from '#/env'
|
||||
|
||||
const MINIMUM_MINIMIZE_TIME = 15 * 60e3
|
||||
|
||||
@@ -192,13 +192,6 @@ export function useOTAUpdates() {
|
||||
return
|
||||
}
|
||||
|
||||
// TEMP: disable wake-from-background OTA loading on Android.
|
||||
// This is causing a crash when the thread view is open due to
|
||||
// `maintainVisibleContentPosition`. See repro repo for more details:
|
||||
// https://github.com/mozzius/ota-crash-repro
|
||||
// Old Arch only - re-enable once we're on the New Archictecture! -sfn
|
||||
if (IS_ANDROID) return
|
||||
|
||||
const subscription = AppState.addEventListener(
|
||||
'change',
|
||||
async nextAppState => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
RQKEY as FEED_RQKEY,
|
||||
} from '#/state/queries/post-feed'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {PostFeed} from '#/view/com/posts/PostFeed'
|
||||
import {PostFeed, type PostFeedRef} from '#/view/com/posts/PostFeed'
|
||||
import {
|
||||
EmptyState,
|
||||
type EmptyStateButtonProps,
|
||||
@@ -35,6 +35,7 @@ interface FeedSectionProps {
|
||||
emptyStateMessage?: string
|
||||
emptyStateButton?: EmptyStateButtonProps
|
||||
emptyStateIcon?: React.ComponentType<any> | React.ReactElement
|
||||
postFeedRef?: React.Ref<PostFeedRef>
|
||||
}
|
||||
|
||||
export function ProfileFeedSection({
|
||||
@@ -48,6 +49,7 @@ export function ProfileFeedSection({
|
||||
emptyStateMessage,
|
||||
emptyStateButton,
|
||||
emptyStateIcon,
|
||||
postFeedRef,
|
||||
}: FeedSectionProps) {
|
||||
const {_} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -110,6 +112,7 @@ export function ProfileFeedSection({
|
||||
shouldUseAdjustedNumToRender ? adjustedInitialNumToRender : undefined
|
||||
}
|
||||
isVideoFeed={isVideoFeed}
|
||||
ref={postFeedRef}
|
||||
/>
|
||||
{(isScrolledDown || hasNew) && (
|
||||
<LoadLatestBtn
|
||||
|
||||
@@ -2,7 +2,7 @@ import {beforeEach, expect, jest, test} from '@jest/globals'
|
||||
|
||||
import {Storage} from '#/storage'
|
||||
|
||||
jest.mock('@bsky.app/react-native-mmkv', () => ({
|
||||
jest.mock('react-native-mmkv', () => ({
|
||||
MMKV: class MMKVMock {
|
||||
_store = new Map()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {MMKV} from '@bsky.app/react-native-mmkv'
|
||||
import {MMKV} from 'react-native-mmkv'
|
||||
|
||||
import {type DB} from '#/storage/archive/db/types'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useCallback, useEffect, useState} from 'react'
|
||||
import {MMKV} from '@bsky.app/react-native-mmkv'
|
||||
import {MMKV} from 'react-native-mmkv'
|
||||
|
||||
import {type Account, type Device} from '#/storage/schema'
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
AppState,
|
||||
@@ -178,6 +186,10 @@ export function getItemsForFeedback(feedRow: FeedRow): {
|
||||
}
|
||||
}
|
||||
|
||||
export type PostFeedRef = {
|
||||
refreshFeed: () => Promise<void>
|
||||
}
|
||||
|
||||
// DISABLED need to check if this is causing random feed refreshes -prf
|
||||
// const REFRESH_AFTER = STALE.HOURS.ONE
|
||||
const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY
|
||||
@@ -204,6 +216,7 @@ let PostFeed = ({
|
||||
savedFeedConfig,
|
||||
initialNumToRender: initialNumToRenderOverride,
|
||||
isVideoFeed = false,
|
||||
ref,
|
||||
}: {
|
||||
feed: FeedDescriptor
|
||||
feedParams?: FeedParams
|
||||
@@ -227,6 +240,7 @@ let PostFeed = ({
|
||||
initialNumToRender?: number
|
||||
isVideoFeed?: boolean
|
||||
lastFetchDate?: () => number
|
||||
ref?: React.Ref<PostFeedRef>
|
||||
}): React.ReactNode => {
|
||||
const ax = useAnalytics()
|
||||
const {t: l} = useLingui()
|
||||
@@ -690,8 +704,9 @@ let PostFeed = ({
|
||||
|
||||
// events
|
||||
// =
|
||||
//
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
const refreshFeed = async () => {
|
||||
if (!enabled) return
|
||||
|
||||
ax.metric('feed:refresh', {
|
||||
@@ -699,24 +714,23 @@ let PostFeed = ({
|
||||
feedUrl: feed,
|
||||
reason: 'pull-to-refresh',
|
||||
})
|
||||
setIsPTRing(true)
|
||||
try {
|
||||
await truncateAndInvalidate(queryClient, RQKEY(feed, feedParams))
|
||||
onHasNew?.(false)
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh posts feed', {message: err})
|
||||
}
|
||||
}
|
||||
|
||||
const onRefresh = async () => {
|
||||
setIsPTRing(true)
|
||||
await refreshFeed()
|
||||
setIsPTRing(false)
|
||||
}, [
|
||||
ax,
|
||||
queryClient,
|
||||
setIsPTRing,
|
||||
onHasNew,
|
||||
feed,
|
||||
feedParams,
|
||||
feedType,
|
||||
enabled,
|
||||
])
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
refreshFeed,
|
||||
}))
|
||||
|
||||
const onEndReached = useCallback(async () => {
|
||||
if (isFetching || !hasNextPage || isError) return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import {SafeAreaView} from 'react-native-safe-area-context'
|
||||
import {ScrollForwarderView} from 'react-native-scroll-forwarder/src'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
moderateProfile,
|
||||
@@ -36,6 +37,7 @@ import {useAgent, useSession} from '#/state/session'
|
||||
import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens'
|
||||
import {ProfileLists} from '#/view/com/lists/ProfileLists'
|
||||
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
|
||||
import {type PostFeedRef} from '#/view/com/posts/PostFeed'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import {type ListRef} from '#/view/com/util/List'
|
||||
@@ -53,7 +55,6 @@ import * as Layout from '#/components/Layout'
|
||||
import {ScreenHider} from '#/components/moderation/ScreenHider'
|
||||
import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks'
|
||||
import {navigate} from '#/Navigation'
|
||||
import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder'
|
||||
|
||||
interface SectionRef {
|
||||
scrollToTop: () => void
|
||||
@@ -187,6 +188,7 @@ function ProfileScreenLoaded({
|
||||
enabled: !!profile.associated?.labeler,
|
||||
})
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const {_} = useLingui()
|
||||
|
||||
const [scrollViewTag, setScrollViewTag] = useState<number | null>(null)
|
||||
@@ -353,6 +355,14 @@ function ProfileScreenLoaded({
|
||||
],
|
||||
})
|
||||
|
||||
const postFeedRef = useRef<PostFeedRef>(null)
|
||||
|
||||
const onRefresh = async () => {
|
||||
setIsRefreshing(true)
|
||||
await postFeedRef.current?.refreshFeed()
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
|
||||
// rendering
|
||||
// =
|
||||
|
||||
@@ -362,7 +372,10 @@ function ProfileScreenLoaded({
|
||||
setMinimumHeight: (height: number) => void
|
||||
}) => {
|
||||
return (
|
||||
<ExpoScrollForwarderView scrollViewTag={scrollViewTag}>
|
||||
<ScrollForwarderView
|
||||
scrollViewTag={scrollViewTag}
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}>
|
||||
<ProfileHeader
|
||||
profile={profile}
|
||||
labeler={labelerInfo}
|
||||
@@ -372,7 +385,7 @@ function ProfileScreenLoaded({
|
||||
isPlaceholderProfile={showPlaceholder}
|
||||
setMinimumHeight={setMinimumHeight}
|
||||
/>
|
||||
</ExpoScrollForwarderView>
|
||||
</ScrollForwarderView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -440,6 +453,7 @@ function ProfileScreenLoaded({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
postFeedRef={postFeedRef}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
Reference in New Issue
Block a user