From 3c580fc86cf0a8048a72baa8c17bb47b9b755f50 Mon Sep 17 00:00:00 2001 From: hailey Date: Tue, 13 May 2025 13:01:47 -0700 Subject: [PATCH] fabric: update scroll forwarder for profile headers (#8366) * progress * remove old module * nil delegates * clean up problems * fix refreshing * bump --- .../expo-module.config.json | 6 - modules/expo-scroll-forwarder/index.ts | 1 - .../ios/ExpoScrollForwarder.podspec | 21 - .../ios/ExpoScrollForwarderModule.swift | 13 - .../src/ExpoScrollForwarder.types.ts | 4 - .../src/ExpoScrollForwarderView.ios.tsx | 13 - .../src/ExpoScrollForwarderView.tsx | 7 - .../ScrollForwarder.podspec | 20 + .../ios/ScrollForwarderView.h | 14 + .../ios/ScrollForwarderView.mm | 411 ++++++++++++++++++ .../ios/ScrollForwarderViewManager.mm | 19 + .../package.json | 19 + .../src/ScrollForwarderView.ios.tsx | 12 + .../src/ScrollForwarderView.tsx | 5 + .../src/ScrollForwarderViewNativeComponent.ts | 16 + .../src/index.tsx | 2 + package.json | 1 + src/screens/Profile/Sections/Feed.tsx | 5 +- src/view/com/posts/PostFeed.tsx | 22 +- src/view/screens/Profile.tsx | 22 +- yarn.lock | 4 + 21 files changed, 564 insertions(+), 73 deletions(-) delete mode 100644 modules/expo-scroll-forwarder/expo-module.config.json delete mode 100644 modules/expo-scroll-forwarder/index.ts delete mode 100644 modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec delete mode 100644 modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift delete mode 100644 modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts delete mode 100644 modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx delete mode 100644 modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx create mode 100644 modules/react-native-scroll-forwarder/ScrollForwarder.podspec create mode 100644 modules/react-native-scroll-forwarder/ios/ScrollForwarderView.h create mode 100644 modules/react-native-scroll-forwarder/ios/ScrollForwarderView.mm create mode 100644 modules/react-native-scroll-forwarder/ios/ScrollForwarderViewManager.mm create mode 100644 modules/react-native-scroll-forwarder/package.json create mode 100644 modules/react-native-scroll-forwarder/src/ScrollForwarderView.ios.tsx create mode 100644 modules/react-native-scroll-forwarder/src/ScrollForwarderView.tsx create mode 100644 modules/react-native-scroll-forwarder/src/ScrollForwarderViewNativeComponent.ts create mode 100644 modules/react-native-scroll-forwarder/src/index.tsx diff --git a/modules/expo-scroll-forwarder/expo-module.config.json b/modules/expo-scroll-forwarder/expo-module.config.json deleted file mode 100644 index 1fd49f79b7..0000000000 --- a/modules/expo-scroll-forwarder/expo-module.config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "platforms": ["ios"], - "ios": { - "modules": ["ExpoScrollForwarderModule"] - } -} diff --git a/modules/expo-scroll-forwarder/index.ts b/modules/expo-scroll-forwarder/index.ts deleted file mode 100644 index a4ad4b8506..0000000000 --- a/modules/expo-scroll-forwarder/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView' diff --git a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec deleted file mode 100644 index 78ca9812e4..0000000000 --- a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec +++ /dev/null @@ -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 diff --git a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift deleted file mode 100644 index 53e2588258..0000000000 --- a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift +++ /dev/null @@ -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 - } - } - } -} diff --git a/modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts b/modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts deleted file mode 100644 index 3f1e4a63da..0000000000 --- a/modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface ExpoScrollForwarderViewProps { - scrollViewTag: number | null - children: React.ReactNode -} diff --git a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx deleted file mode 100644 index 18bdb25c8a..0000000000 --- a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {requireNativeViewManager} from 'expo-modules-core' - -import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types' - -const NativeView: React.ComponentType = - requireNativeViewManager('ExpoScrollForwarder') - -export function ExpoScrollForwarderView({ - children, - ...rest -}: ExpoScrollForwarderViewProps) { - return {children} -} diff --git a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx deleted file mode 100644 index d66eef7cbd..0000000000 --- a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types' - -export function ExpoScrollForwarderView({ - children, -}: React.PropsWithChildren) { - return children -} diff --git a/modules/react-native-scroll-forwarder/ScrollForwarder.podspec b/modules/react-native-scroll-forwarder/ScrollForwarder.podspec new file mode 100644 index 0000000000..137aea92b8 --- /dev/null +++ b/modules/react-native-scroll-forwarder/ScrollForwarder.podspec @@ -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 diff --git a/modules/react-native-scroll-forwarder/ios/ScrollForwarderView.h b/modules/react-native-scroll-forwarder/ios/ScrollForwarderView.h new file mode 100644 index 0000000000..3b3b4898ab --- /dev/null +++ b/modules/react-native-scroll-forwarder/ios/ScrollForwarderView.h @@ -0,0 +1,14 @@ +#import +#import + +#ifndef ScrollForwarderViewNativeComponent_h +#define ScrollForwarderViewNativeComponent_h + +NS_ASSUME_NONNULL_BEGIN + +@interface ScrollForwarderView : RCTViewComponentView +@end + +NS_ASSUME_NONNULL_END + +#endif /* ScrollForwarderViewNativeComponent_h */ diff --git a/modules/react-native-scroll-forwarder/ios/ScrollForwarderView.mm b/modules/react-native-scroll-forwarder/ios/ScrollForwarderView.mm new file mode 100644 index 0000000000..840cd04ff3 --- /dev/null +++ b/modules/react-native-scroll-forwarder/ios/ScrollForwarderView.mm @@ -0,0 +1,411 @@ +#import "ScrollForwarderView.h" + +#import +#import +#import +#import +#import +#import + +#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 () + +@end + +@implementation ScrollForwarderView { + NSArray * _cancelGestureRecognizers; + RCTScrollViewComponentView * _svcv; + CGPoint _initialOffset; + + CADisplayLink * _displayLink; + CGFloat _currentVelocity; + CGFloat _accumulatedTranslation; + + bool _didImpact; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _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 *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(_props); + const auto &newViewProps = *std::static_pointer_cast(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 *)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(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 ScrollForwarderViewCls(void) +{ + return ScrollForwarderView.class; +} + +@end diff --git a/modules/react-native-scroll-forwarder/ios/ScrollForwarderViewManager.mm b/modules/react-native-scroll-forwarder/ios/ScrollForwarderViewManager.mm new file mode 100644 index 0000000000..5f3a464c48 --- /dev/null +++ b/modules/react-native-scroll-forwarder/ios/ScrollForwarderViewManager.mm @@ -0,0 +1,19 @@ +#import +#import +#import "RCTBridge.h" + +@interface ScrollForwarderViewManager : RCTViewManager +@end + +@implementation ScrollForwarderViewManager + +RCT_EXPORT_MODULE(ScrollForwarderView) + +- (UIView *)view +{ + return [[UIView alloc] init]; +} + +RCT_EXPORT_VIEW_PROPERTY(scrollViewTag, NSNumber) + +@end diff --git a/modules/react-native-scroll-forwarder/package.json b/modules/react-native-scroll-forwarder/package.json new file mode 100644 index 0000000000..2f766a402e --- /dev/null +++ b/modules/react-native-scroll-forwarder/package.json @@ -0,0 +1,19 @@ +{ + "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" + }, + "author": "Hailey ", + "license": "MIT", + "homepage": "#readme", + "create-react-native-library": { + "languages": "kotlin-objc", + "type": "fabric-view", + "version": "0.50.2" + } +} diff --git a/modules/react-native-scroll-forwarder/src/ScrollForwarderView.ios.tsx b/modules/react-native-scroll-forwarder/src/ScrollForwarderView.ios.tsx new file mode 100644 index 0000000000..497112ad11 --- /dev/null +++ b/modules/react-native-scroll-forwarder/src/ScrollForwarderView.ios.tsx @@ -0,0 +1,12 @@ +import { + default as NativeScrollForwarderView, + type NativeProps, +} from './ScrollForwarderViewNativeComponent' + +export function ScrollForwarderView({children, ...rest}: NativeProps) { + return ( + + {children} + + ) +} diff --git a/modules/react-native-scroll-forwarder/src/ScrollForwarderView.tsx b/modules/react-native-scroll-forwarder/src/ScrollForwarderView.tsx new file mode 100644 index 0000000000..315605aae5 --- /dev/null +++ b/modules/react-native-scroll-forwarder/src/ScrollForwarderView.tsx @@ -0,0 +1,5 @@ +import {type NativeProps} from './ScrollForwarderViewNativeComponent' + +export function ScrollForwarderView({children}: NativeProps) { + return children +} diff --git a/modules/react-native-scroll-forwarder/src/ScrollForwarderViewNativeComponent.ts b/modules/react-native-scroll-forwarder/src/ScrollForwarderViewNativeComponent.ts new file mode 100644 index 0000000000..9c6348de32 --- /dev/null +++ b/modules/react-native-scroll-forwarder/src/ScrollForwarderViewNativeComponent.ts @@ -0,0 +1,16 @@ +import {type ViewProps} from 'react-native' +import { + type BubblingEventHandler, + type Int32, +} from 'react-native/Libraries/Types/CodegenTypes' +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent' + +type OnRefreshEvent = {} + +export interface NativeProps extends ViewProps { + scrollViewTag: Int32 | null + refreshing?: boolean + onRefresh?: BubblingEventHandler +} + +export default codegenNativeComponent('ScrollForwarderView') diff --git a/modules/react-native-scroll-forwarder/src/index.tsx b/modules/react-native-scroll-forwarder/src/index.tsx new file mode 100644 index 0000000000..2210e5d9f2 --- /dev/null +++ b/modules/react-native-scroll-forwarder/src/index.tsx @@ -0,0 +1,2 @@ +export {ScrollForwarderView} from './ScrollForwarderView' +export * from './ScrollForwarderViewNativeComponent' diff --git a/package.json b/package.json index 504fc85f4d..b3ee27c657 100644 --- a/package.json +++ b/package.json @@ -204,6 +204,7 @@ "react-native-reanimated": "^3.19.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "^4.19.0", + "react-native-scroll-forwarder": "link:./modules/react-native-scroll-forwarder" "react-native-svg": "15.12.1", "react-native-uitextview": "2.0.4", "react-native-url-polyfill": "^1.3.0", diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx index 1591218b78..94509f693c 100644 --- a/src/screens/Profile/Sections/Feed.tsx +++ b/src/screens/Profile/Sections/Feed.tsx @@ -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, @@ -34,6 +34,7 @@ interface FeedSectionProps { emptyStateMessage?: string emptyStateButton?: EmptyStateButtonProps emptyStateIcon?: React.ComponentType | React.ReactElement + postFeedRef?: React.Ref } export function ProfileFeedSection({ @@ -47,6 +48,7 @@ export function ProfileFeedSection({ emptyStateMessage, emptyStateButton, emptyStateIcon, + postFeedRef, }: FeedSectionProps) { const {_} = useLingui() const queryClient = useQueryClient() @@ -109,6 +111,7 @@ export function ProfileFeedSection({ shouldUseAdjustedNumToRender ? adjustedInitialNumToRender : undefined } isVideoFeed={isVideoFeed} + ref={postFeedRef} /> {(isScrolledDown || hasNew) && ( Promise +} + // 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 +209,7 @@ let PostFeed = ({ savedFeedConfig, initialNumToRender: initialNumToRenderOverride, isVideoFeed = false, + ref, }: { feed: FeedDescriptor feedParams?: FeedParams @@ -226,6 +232,7 @@ let PostFeed = ({ savedFeedConfig?: AppBskyActorDefs.SavedFeed initialNumToRender?: number isVideoFeed?: boolean + ref?: React.Ref }): React.ReactNode => { const {_} = useLingui() const queryClient = useQueryClient() @@ -687,22 +694,31 @@ let PostFeed = ({ // events // = + // - const onRefresh = useCallback(async () => { + const refreshFeed = async () => { logEvent('feed:refresh', { feedType: feedType, feedUrl: feed, reason: 'pull-to-refresh', }) - setIsPTRing(true) try { await refetch() onHasNew?.(false) } catch (err) { logger.error('Failed to refresh posts feed', {message: err}) } + } + + const onRefresh = async () => { + setIsPTRing(true) + await refreshFeed() setIsPTRing(false) - }, [refetch, setIsPTRing, onHasNew, feed, feedType]) + } + + useImperativeHandle(ref, () => ({ + refreshFeed, + })) const onEndReached = useCallback(async () => { if (isFetching || !hasNextPage || isError) return diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 5bb46f0d29..4847ecbf63 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo} from 'react' +import React, {useCallback, useMemo, useRef, useState} from 'react' import {StyleSheet} from 'react-native' import {SafeAreaView} from 'react-native-safe-area-context' import { @@ -11,6 +11,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect, useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' +import {ScrollForwarderView} from 'modules/react-native-scroll-forwarder/src' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' @@ -37,6 +38,7 @@ import {useSetMinimalShellMode} from '#/state/shell' 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] = React.useState(0) + const [isRefreshing, setIsRefreshing] = useState(false) const {_} = useLingui() const [scrollViewTag, setScrollViewTag] = React.useState(null) @@ -354,6 +356,14 @@ function ProfileScreenLoaded({ ], }) + const postFeedRef = useRef(null) + + const onRefresh = async () => { + setIsRefreshing(true) + await postFeedRef.current?.refreshFeed() + setIsRefreshing(false) + } + // rendering // = @@ -363,7 +373,10 @@ function ProfileScreenLoaded({ setMinimumHeight: (height: number) => void }) => { return ( - + - + ) } @@ -440,6 +453,7 @@ function ProfileScreenLoaded({ } : undefined } + postFeedRef={postFeedRef} /> ) : null} diff --git a/yarn.lock b/yarn.lock index 15065a0edc..17e00a82fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16799,6 +16799,10 @@ react-native-screens@^4.19.0: react-freeze "^1.0.0" warn-once "^0.1.0" +"react-native-scroll-forwarder@link:./modules/react-native-scroll-forwarder": + version "0.0.0" + uid "" + react-native-svg@15.12.1: version "15.12.1" resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-15.12.1.tgz#7ba756dd6a235f86a2c312a1e7911f9b0d18ad3a"