Compare commits

...

10 Commits

Author SHA1 Message Date
Samuel Newman e981e4645c reenable wake-from-background OTA on Android 2026-05-28 13:00:35 +03:00
Samuel Newman 82503dd05e use debugOptimised variant on Android 2026-05-28 12:50:44 +03:00
Samuel Newman 84c75aea23 fix android text inputs 2026-05-28 12:49:25 +03:00
Samuel Newman 790c36b0b7 rm absoluteFillObject 2026-05-28 12:47:09 +03:00
Samuel Newman b6876c6a58 Update reanimated babel plugin 2026-05-28 12:46:54 +03:00
Samuel Newman 52f4cb0b73 rm react-native-dotenv 2026-05-28 12:46:22 +03:00
Samuel Newman a5181c785a delete bridgeful code 2026-05-28 12:17:45 +03:00
Samuel Newman 0a7554d5c1 replace expo-scroll-forwarder with obj-c version 2026-05-28 12:13:54 +03:00
Samuel Newman 109488fe09 big dependency update 2026-05-28 11:58:50 +03:00
Hailey b6ca2fe5b4 fix bottom sheet 2026-05-27 21:28:51 +03:00
42 changed files with 3023 additions and 2837 deletions
+1 -6
View File
@@ -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
View File
@@ -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: {
+20 -20
View File
@@ -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
-116
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+2299 -2126
View File
File diff suppressed because it is too large Load Diff
+10 -19
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
// }
// />
}
</>
)
}
+19 -19
View File
@@ -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`)}
-5
View File
@@ -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>()
+1 -8
View File
@@ -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 => {
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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'
+27 -13
View File
@@ -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
+17 -3
View File
@@ -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}