about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--modules/expo-scroll-forwarder/expo-module.config.json6
-rw-r--r--modules/expo-scroll-forwarder/index.ts1
-rw-r--r--modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec21
-rw-r--r--modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift13
-rw-r--r--modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift215
-rw-r--r--modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts6
-rw-r--r--modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx13
-rw-r--r--modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx7
-rw-r--r--patches/react-native+0.73.2.patch58
-rw-r--r--patches/react-native+0.73.2.patch.md12
-rw-r--r--src/screens/Profile/Sections/Feed.tsx34
-rw-r--r--src/screens/Profile/Sections/Labels.tsx13
-rw-r--r--src/view/com/feeds/ProfileFeedgens.tsx43
-rw-r--r--src/view/com/lists/ProfileLists.tsx43
-rw-r--r--src/view/screens/Profile.tsx69
15 files changed, 490 insertions, 64 deletions
diff --git a/modules/expo-scroll-forwarder/expo-module.config.json b/modules/expo-scroll-forwarder/expo-module.config.json
new file mode 100644
index 000000000..1fd49f79b
--- /dev/null
+++ b/modules/expo-scroll-forwarder/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+  "platforms": ["ios"],
+  "ios": {
+    "modules": ["ExpoScrollForwarderModule"]
+  }
+}
diff --git a/modules/expo-scroll-forwarder/index.ts b/modules/expo-scroll-forwarder/index.ts
new file mode 100644
index 000000000..a4ad4b850
--- /dev/null
+++ b/modules/expo-scroll-forwarder/index.ts
@@ -0,0 +1 @@
+export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView'
diff --git a/modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec
new file mode 100644
index 000000000..78ca9812e
--- /dev/null
+++ b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec
@@ -0,0 +1,21 @@
+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
new file mode 100644
index 000000000..c4ecc788e
--- /dev/null
+++ b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift
@@ -0,0 +1,13 @@
+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/ios/ExpoScrollForwarderView.swift b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift
new file mode 100644
index 000000000..9c0e2f872
--- /dev/null
+++ b/modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift
@@ -0,0 +1,215 @@
+import ExpoModulesCore
+
+// 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) -> Void {
+    self.stopTimer()
+  }
+  
+  @IBAction func callOnPan(_ sender: UIPanGestureRecognizer) -> Void {
+    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) { timer 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) -> Void {
+    self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated)
+  }
+
+  func stopTimer() -> Void {
+    self.disableCancelGestureRecognizers()
+    self.animTimer?.invalidate()
+    self.animTimer = nil
+  }
+}
diff --git a/modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts b/modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts
new file mode 100644
index 000000000..26b9e7553
--- /dev/null
+++ b/modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts
@@ -0,0 +1,6 @@
+import React from 'react'
+
+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
new file mode 100644
index 000000000..a91aebd4d
--- /dev/null
+++ b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx
@@ -0,0 +1,13 @@
+import {requireNativeViewManager} from 'expo-modules-core'
+import * as React from 'react'
+import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
+
+const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
+  requireNativeViewManager('ExpoScrollForwarder')
+
+export function ExpoScrollForwarderView({
+  children,
+  ...rest
+}: ExpoScrollForwarderViewProps) {
+  return <NativeView {...rest}>{children}</NativeView>
+}
diff --git a/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx
new file mode 100644
index 000000000..93e69333f
--- /dev/null
+++ b/modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx
@@ -0,0 +1,7 @@
+import React from 'react'
+import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
+export function ExpoScrollForwarderView({
+  children,
+}: React.PropsWithChildren<ExpoScrollForwarderViewProps>) {
+  return children
+}
diff --git a/patches/react-native+0.73.2.patch b/patches/react-native+0.73.2.patch
index 8db23da0c..db8b7da2d 100644
--- a/patches/react-native+0.73.2.patch
+++ b/patches/react-native+0.73.2.patch
@@ -1,11 +1,22 @@
+diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
+index e9b330f..1ecdf0a 100644
+--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
++++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
+@@ -16,4 +16,6 @@
+ @property (nonatomic, copy) RCTDirectEventBlock onRefresh;
+ @property (nonatomic, weak) UIScrollView *scrollView;
+ 
++- (void)forwarderBeginRefreshing;
++
+ @end
 diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
-index b09e653..d290dab 100644
+index b09e653..4c32b31 100644
 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
 +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
-@@ -198,6 +198,14 @@ - (void)refreshControlValueChanged
+@@ -198,9 +198,53 @@ - (void)refreshControlValueChanged
    [self setCurrentRefreshingState:super.refreshing];
    _refreshingProgrammatically = NO;
-
+ 
 +  if (@available(iOS 17.4, *)) {
 +    if (_currentRefreshingState) {
 +      UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
@@ -16,4 +27,43 @@ index b09e653..d290dab 100644
 +
    if (_onRefresh) {
      _onRefresh(nil);
-   }
\ No newline at end of file
+   }
+ }
+ 
++/*
++ This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
++ libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
++ function.
++ */
++- (void)forwarderBeginRefreshing
++{
++  _refreshingProgrammatically = NO;
++  
++  [self sizeToFit];
++  
++  if (!self.scrollView) {
++    return;
++  }
++  
++  UIScrollView *scrollView = (UIScrollView *)self.scrollView;
++  
++  [UIView animateWithDuration:0.3
++    delay:0
++    options:UIViewAnimationOptionBeginFromCurrentState
++    animations:^(void) {
++      // Whenever we call this method, the scrollview will always be at a position of
++      // -130 or less. Scrolling back to -65 simulates the default behavior of RCTRefreshControl
++      [scrollView setContentOffset:CGPointMake(0, -65)];
++    }
++    completion:^(__unused BOOL finished) {
++      [super beginRefreshing];
++      [self setCurrentRefreshingState:super.refreshing];
++    
++      if (self->_onRefresh) {
++        self->_onRefresh(nil);
++      }
++    }
++  ];
++}
++
+ @end
diff --git a/patches/react-native+0.73.2.patch.md b/patches/react-native+0.73.2.patch.md
index 7f70baf2f..9c93aee5c 100644
--- a/patches/react-native+0.73.2.patch.md
+++ b/patches/react-native+0.73.2.patch.md
@@ -1,5 +1,13 @@
-# RefreshControl Patch
+# ***This second part of this patch is load bearing, do not remove.***
+
+## RefreshControl Patch - iOS 17.4 Haptic Regression
 
 Patching `RCTRefreshControl.mm` temporarily to play an impact haptic on refresh when using iOS 17.4 or higher. Since
 17.4, there has been a regression somewhere causing haptics to not play on iOS on refresh. Should monitor for an update
-in the RN repo: https://github.com/facebook/react-native/issues/43388
\ No newline at end of file
+in the RN repo: https://github.com/facebook/react-native/issues/43388
+
+## RefreshControl Path - ScrollForwarder
+
+Patching `RCTRefreshControl.m` and `RCTRefreshControl.h` to add a new `forwarderBeginRefreshing` method to the class.
+This method is used by `ExpoScrollForwarder` to initiate a refresh of the underlying `UIScrollView` from inside that
+module.
diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx
index 0a5e2208d..bc106fcfb 100644
--- a/src/screens/Profile/Sections/Feed.tsx
+++ b/src/screens/Profile/Sections/Feed.tsx
@@ -1,18 +1,19 @@
 import React from 'react'
-import {View} from 'react-native'
+import {findNodeHandle, View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {ListRef} from 'view/com/util/List'
-import {Feed} from 'view/com/posts/Feed'
-import {EmptyState} from 'view/com/util/EmptyState'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {isNative} from '#/platform/detection'
 import {FeedDescriptor} from '#/state/queries/post-feed'
 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
-import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
-import {useQueryClient} from '@tanstack/react-query'
 import {truncateAndInvalidate} from '#/state/queries/util'
-import {Text} from '#/view/com/util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
-import {isNative} from '#/platform/detection'
+import {Text} from '#/view/com/util/text/Text'
+import {Feed} from 'view/com/posts/Feed'
+import {EmptyState} from 'view/com/util/EmptyState'
+import {ListRef} from 'view/com/util/List'
+import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
 import {SectionRef} from './types'
 
 interface FeedSectionProps {
@@ -21,12 +22,20 @@ interface FeedSectionProps {
   isFocused: boolean
   scrollElRef: ListRef
   ignoreFilterFor?: string
+  setScrollViewTag: (tag: number | null) => void
 }
 export const ProfileFeedSection = React.forwardRef<
   SectionRef,
   FeedSectionProps
 >(function FeedSectionImpl(
-  {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor},
+  {
+    feed,
+    headerHeight,
+    isFocused,
+    scrollElRef,
+    ignoreFilterFor,
+    setScrollViewTag,
+  },
   ref,
 ) {
   const {_} = useLingui()
@@ -50,6 +59,13 @@ export const ProfileFeedSection = React.forwardRef<
     return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} />
   }, [_])
 
+  React.useEffect(() => {
+    if (isFocused && scrollElRef.current) {
+      const nativeTag = findNodeHandle(scrollElRef.current)
+      setScrollViewTag(nativeTag)
+    }
+  }, [isFocused, scrollElRef, setScrollViewTag])
+
   return (
     <View>
       <Feed
diff --git a/src/screens/Profile/Sections/Labels.tsx b/src/screens/Profile/Sections/Labels.tsx
index 5ba8f00a5..f43e3633d 100644
--- a/src/screens/Profile/Sections/Labels.tsx
+++ b/src/screens/Profile/Sections/Labels.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {View} from 'react-native'
+import {findNodeHandle, View} from 'react-native'
 import {useSafeAreaFrame} from 'react-native-safe-area-context'
 import {
   AppBskyLabelerDefs,
@@ -32,6 +32,8 @@ interface LabelsSectionProps {
   moderationOpts: ModerationOpts
   scrollElRef: ListRef
   headerHeight: number
+  isFocused: boolean
+  setScrollViewTag: (tag: number | null) => void
 }
 export const ProfileLabelsSection = React.forwardRef<
   SectionRef,
@@ -44,6 +46,8 @@ export const ProfileLabelsSection = React.forwardRef<
     moderationOpts,
     scrollElRef,
     headerHeight,
+    isFocused,
+    setScrollViewTag,
   },
   ref,
 ) {
@@ -63,6 +67,13 @@ export const ProfileLabelsSection = React.forwardRef<
     scrollToTop: onScrollToTop,
   }))
 
+  React.useEffect(() => {
+    if (isFocused && scrollElRef.current) {
+      const nativeTag = findNodeHandle(scrollElRef.current)
+      setScrollViewTag(nativeTag)
+    }
+  }, [isFocused, scrollElRef, setScrollViewTag])
+
   return (
     <CenteredView style={{flex: 1, minHeight}} sideBorders>
       {isLabelerLoading ? (
diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx
index e9cf9e535..a006b11c0 100644
--- a/src/view/com/feeds/ProfileFeedgens.tsx
+++ b/src/view/com/feeds/ProfileFeedgens.tsx
@@ -1,22 +1,29 @@
 import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {
+  findNodeHandle,
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
-import {List, ListRef} from '../util/List'
-import {FeedSourceCardLoaded} from './FeedSourceCard'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
-import {Text} from '../util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens'
-import {logger} from '#/logger'
-import {Trans, msg} from '@lingui/macro'
+
 import {cleanError} from '#/lib/strings/errors'
 import {useTheme} from '#/lib/ThemeContext'
-import {usePreferencesQuery} from '#/state/queries/preferences'
+import {logger} from '#/logger'
+import {isNative} from '#/platform/detection'
 import {hydrateFeedGenerator} from '#/state/queries/feed'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens'
+import {usePalette} from 'lib/hooks/usePalette'
 import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
-import {isNative} from '#/platform/detection'
-import {useLingui} from '@lingui/react'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {List, ListRef} from '../util/List'
+import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
+import {Text} from '../util/text/Text'
+import {FeedSourceCardLoaded} from './FeedSourceCard'
 
 const LOADING = {_reactKey: '__loading__'}
 const EMPTY = {_reactKey: '__empty__'}
@@ -34,13 +41,14 @@ interface ProfileFeedgensProps {
   enabled?: boolean
   style?: StyleProp<ViewStyle>
   testID?: string
+  setScrollViewTag: (tag: number | null) => void
 }
 
 export const ProfileFeedgens = React.forwardRef<
   SectionRef,
   ProfileFeedgensProps
 >(function ProfileFeedgensImpl(
-  {did, scrollElRef, headerOffset, enabled, style, testID},
+  {did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag},
   ref,
 ) {
   const pal = usePalette('default')
@@ -169,6 +177,13 @@ export const ProfileFeedgens = React.forwardRef<
     [error, refetch, onPressRetryLoadMore, pal, preferences, _],
   )
 
+  React.useEffect(() => {
+    if (enabled && scrollElRef.current) {
+      const nativeTag = findNodeHandle(scrollElRef.current)
+      setScrollViewTag(nativeTag)
+    }
+  }, [enabled, scrollElRef, setScrollViewTag])
+
   return (
     <View testID={testID} style={style}>
       <List
diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx
index a47b25bed..003d1c60e 100644
--- a/src/view/com/lists/ProfileLists.tsx
+++ b/src/view/com/lists/ProfileLists.tsx
@@ -1,21 +1,28 @@
 import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {
+  findNodeHandle,
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
-import {List, ListRef} from '../util/List'
-import {ListCard} from './ListCard'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
-import {Text} from '../util/text/Text'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useProfileListsQuery, RQKEY} from '#/state/queries/profile-lists'
-import {logger} from '#/logger'
-import {Trans, msg} from '@lingui/macro'
+
 import {cleanError} from '#/lib/strings/errors'
 import {useTheme} from '#/lib/ThemeContext'
-import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {logger} from '#/logger'
 import {isNative} from '#/platform/detection'
-import {useLingui} from '@lingui/react'
+import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {usePalette} from 'lib/hooks/usePalette'
+import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {List, ListRef} from '../util/List'
+import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
+import {Text} from '../util/text/Text'
+import {ListCard} from './ListCard'
 
 const LOADING = {_reactKey: '__loading__'}
 const EMPTY = {_reactKey: '__empty__'}
@@ -33,11 +40,12 @@ interface ProfileListsProps {
   enabled?: boolean
   style?: StyleProp<ViewStyle>
   testID?: string
+  setScrollViewTag: (tag: number | null) => void
 }
 
 export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
   function ProfileListsImpl(
-    {did, scrollElRef, headerOffset, enabled, style, testID},
+    {did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag},
     ref,
   ) {
     const pal = usePalette('default')
@@ -171,6 +179,13 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
       [error, refetch, onPressRetryLoadMore, pal, _],
     )
 
+    React.useEffect(() => {
+      if (enabled && scrollElRef.current) {
+        const nativeTag = findNodeHandle(scrollElRef.current)
+        setScrollViewTag(nativeTag)
+      }
+    }, [enabled, scrollElRef, setScrollViewTag])
+
     return (
       <View testID={testID} style={style}>
         <List
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 6073b9571..c391f8050 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -12,9 +12,7 @@ import {useFocusEffect} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
 import {cleanError} from '#/lib/strings/errors'
-import {isInvalidHandle} from '#/lib/strings/handles'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
-import {listenSoftReset} from '#/state/events'
 import {useLabelerInfoQuery} from '#/state/queries/labeler'
 import {resetProfilePostsQueries} from '#/state/queries/post-feed'
 import {useModerationOpts} from '#/state/queries/preferences'
@@ -27,13 +25,17 @@ import {useAnalytics} from 'lib/analytics/analytics'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {ComposeIcon2} from 'lib/icons'
 import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
+import {useGate} from 'lib/statsig/statsig'
 import {combinedDisplayName} from 'lib/strings/display-names'
+import {isInvalidHandle} from 'lib/strings/handles'
 import {colors, s} from 'lib/styles'
+import {listenSoftReset} from 'state/events'
 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
 import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
 import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
 import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
 import {ScreenHider} from '#/components/moderation/ScreenHider'
+import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder'
 import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens'
 import {ProfileLists} from '../com/lists/ProfileLists'
 import {ErrorScreen} from '../com/util/error/ErrorScreen'
@@ -141,6 +143,7 @@ function ProfileScreenLoaded({
   const setMinimalShellMode = useSetMinimalShellMode()
   const {openComposer} = useComposerControls()
   const {screen, track} = useAnalytics()
+  const shouldUseScrollableHeader = useGate('new_profile_scroll_component')
   const {
     data: labelerInfo,
     error: labelerError,
@@ -152,6 +155,9 @@ function ProfileScreenLoaded({
   const [currentPage, setCurrentPage] = React.useState(0)
   const {_} = useLingui()
   const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
+
+  const [scrollViewTag, setScrollViewTag] = React.useState<number | null>(null)
+
   const postsSectionRef = React.useRef<SectionRef>(null)
   const repliesSectionRef = React.useRef<SectionRef>(null)
   const mediaSectionRef = React.useRef<SectionRef>(null)
@@ -297,12 +303,9 @@ function ProfileScreenLoaded({
     openComposer({mention})
   }, [openComposer, currentAccount, track, profile])
 
-  const onPageSelected = React.useCallback(
-    (i: number) => {
-      setCurrentPage(i)
-    },
-    [setCurrentPage],
-  )
+  const onPageSelected = React.useCallback((i: number) => {
+    setCurrentPage(i)
+  }, [])
 
   const onCurrentPageSelected = React.useCallback(
     (index: number) => {
@@ -315,21 +318,38 @@ function ProfileScreenLoaded({
   // =
 
   const renderHeader = React.useCallback(() => {
-    return (
-      <ProfileHeader
-        profile={profile}
-        labeler={labelerInfo}
-        descriptionRT={hasDescription ? descriptionRT : null}
-        moderationOpts={moderationOpts}
-        hideBackButton={hideBackButton}
-        isPlaceholderProfile={showPlaceholder}
-      />
-    )
+    if (shouldUseScrollableHeader) {
+      return (
+        <ExpoScrollForwarderView scrollViewTag={scrollViewTag}>
+          <ProfileHeader
+            profile={profile}
+            labeler={labelerInfo}
+            descriptionRT={hasDescription ? descriptionRT : null}
+            moderationOpts={moderationOpts}
+            hideBackButton={hideBackButton}
+            isPlaceholderProfile={showPlaceholder}
+          />
+        </ExpoScrollForwarderView>
+      )
+    } else {
+      return (
+        <ProfileHeader
+          profile={profile}
+          labeler={labelerInfo}
+          descriptionRT={hasDescription ? descriptionRT : null}
+          moderationOpts={moderationOpts}
+          hideBackButton={hideBackButton}
+          isPlaceholderProfile={showPlaceholder}
+        />
+      )
+    }
   }, [
+    shouldUseScrollableHeader,
+    scrollViewTag,
     profile,
     labelerInfo,
-    descriptionRT,
     hasDescription,
+    descriptionRT,
     moderationOpts,
     hideBackButton,
     showPlaceholder,
@@ -349,7 +369,7 @@ function ProfileScreenLoaded({
         onCurrentPageSelected={onCurrentPageSelected}
         renderHeader={renderHeader}>
         {showFiltersTab
-          ? ({headerHeight, scrollElRef}) => (
+          ? ({headerHeight, isFocused, scrollElRef}) => (
               <ProfileLabelsSection
                 ref={labelsSectionRef}
                 labelerInfo={labelerInfo}
@@ -358,6 +378,8 @@ function ProfileScreenLoaded({
                 moderationOpts={moderationOpts}
                 scrollElRef={scrollElRef as ListRef}
                 headerHeight={headerHeight}
+                isFocused={isFocused}
+                setScrollViewTag={setScrollViewTag}
               />
             )
           : null}
@@ -369,6 +391,7 @@ function ProfileScreenLoaded({
                 scrollElRef={scrollElRef as ListRef}
                 headerOffset={headerHeight}
                 enabled={isFocused}
+                setScrollViewTag={setScrollViewTag}
               />
             )
           : null}
@@ -381,6 +404,7 @@ function ProfileScreenLoaded({
                 isFocused={isFocused}
                 scrollElRef={scrollElRef as ListRef}
                 ignoreFilterFor={profile.did}
+                setScrollViewTag={setScrollViewTag}
               />
             )
           : null}
@@ -393,6 +417,7 @@ function ProfileScreenLoaded({
                 isFocused={isFocused}
                 scrollElRef={scrollElRef as ListRef}
                 ignoreFilterFor={profile.did}
+                setScrollViewTag={setScrollViewTag}
               />
             )
           : null}
@@ -405,6 +430,7 @@ function ProfileScreenLoaded({
                 isFocused={isFocused}
                 scrollElRef={scrollElRef as ListRef}
                 ignoreFilterFor={profile.did}
+                setScrollViewTag={setScrollViewTag}
               />
             )
           : null}
@@ -417,6 +443,7 @@ function ProfileScreenLoaded({
                 isFocused={isFocused}
                 scrollElRef={scrollElRef as ListRef}
                 ignoreFilterFor={profile.did}
+                setScrollViewTag={setScrollViewTag}
               />
             )
           : null}
@@ -428,6 +455,7 @@ function ProfileScreenLoaded({
                 scrollElRef={scrollElRef as ListRef}
                 headerOffset={headerHeight}
                 enabled={isFocused}
+                setScrollViewTag={setScrollViewTag}
               />
             )
           : null}
@@ -439,6 +467,7 @@ function ProfileScreenLoaded({
                 scrollElRef={scrollElRef as ListRef}
                 headerOffset={headerHeight}
                 enabled={isFocused}
+                setScrollViewTag={setScrollViewTag}
               />
             )
           : null}