Bluesky app fork with some witchin' additions 💫

Make bio area scrollable on iOS (#2931)

* fix dampen logic

prevent ghost presses

handle refreshes, animations, and clamps

handle most cases for cancelling the scroll animation

handle animations

save point

simplify

remove unnecessary context

readme

apply offset on pan

find the RCTScrollView

send props, add native gesture recognizer

get the react tag

wrap the profile in context

create module

* fix swiping to go back

* remove debug

* use `findNodeHandle`

* create an expo module view

* port most of it to expo modules

* finish most of expomodules impl

* experiments

* remove refresh ability for now

* remove rn module

* changes

* cleanup a few issues

allow swipe back gesture

clean up types

always run animation if the final offset is < 0

separate logic

update patch readme

get the `RCTRefreshControl` working nicely

* gate new header

* organize

authored by hailey.at and committed by

GitHub 4e517720 740cd029

+490 -64
+6
modules/expo-scroll-forwarder/expo-module.config.json
···
··· 1 + { 2 + "platforms": ["ios"], 3 + "ios": { 4 + "modules": ["ExpoScrollForwarderModule"] 5 + } 6 + }
+1
modules/expo-scroll-forwarder/index.ts
···
··· 1 + export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView'
+21
modules/expo-scroll-forwarder/ios/ExpoScrollForwarder.podspec
···
··· 1 + Pod::Spec.new do |s| 2 + s.name = 'ExpoScrollForwarder' 3 + s.version = '1.0.0' 4 + s.summary = 'Forward scroll gesture from UIView to UIScrollView' 5 + s.description = 'Forward scroll gesture from UIView to UIScrollView' 6 + s.author = 'bluesky-social' 7 + s.homepage = 'https://github.com/bluesky-social/social-app' 8 + s.platforms = { :ios => '13.4', :tvos => '13.4' } 9 + s.source = { git: '' } 10 + s.static_framework = true 11 + 12 + s.dependency 'ExpoModulesCore' 13 + 14 + # Swift/Objective-C compatibility 15 + s.pod_target_xcconfig = { 16 + 'DEFINES_MODULE' => 'YES', 17 + 'SWIFT_COMPILATION_MODE' => 'wholemodule' 18 + } 19 + 20 + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" 21 + end
+13
modules/expo-scroll-forwarder/ios/ExpoScrollForwarderModule.swift
···
··· 1 + import ExpoModulesCore 2 + 3 + public class ExpoScrollForwarderModule: Module { 4 + public func definition() -> ModuleDefinition { 5 + Name("ExpoScrollForwarder") 6 + 7 + View(ExpoScrollForwarderView.self) { 8 + Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in 9 + view.scrollViewTag = prop 10 + } 11 + } 12 + } 13 + }
+215
modules/expo-scroll-forwarder/ios/ExpoScrollForwarderView.swift
···
··· 1 + import ExpoModulesCore 2 + 3 + // This view will be used as a native component. Make sure to inherit from `ExpoView` 4 + // to apply the proper styling (e.g. border radius and shadows). 5 + class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate { 6 + var scrollViewTag: Int? { 7 + didSet { 8 + self.tryFindScrollView() 9 + } 10 + } 11 + 12 + private var rctScrollView: RCTScrollView? 13 + private var rctRefreshCtrl: RCTRefreshControl? 14 + private var cancelGestureRecognizers: [UIGestureRecognizer]? 15 + private var animTimer: Timer? 16 + private var initialOffset: CGFloat = 0.0 17 + private var didImpact: Bool = false 18 + 19 + required init(appContext: AppContext? = nil) { 20 + super.init(appContext: appContext) 21 + 22 + let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:))) 23 + pg.delegate = self 24 + self.addGestureRecognizer(pg) 25 + 26 + let tg = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:))) 27 + tg.isEnabled = false 28 + tg.delegate = self 29 + 30 + let lpg = UILongPressGestureRecognizer(target: self, action: #selector(callOnPress(_:))) 31 + lpg.minimumPressDuration = 0.01 32 + lpg.isEnabled = false 33 + lpg.delegate = self 34 + 35 + self.cancelGestureRecognizers = [lpg, tg] 36 + } 37 + 38 + 39 + // We don't want to recognize the scroll pan gesture and the swipe back gesture together 40 + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { 41 + if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer { 42 + return false 43 + } 44 + 45 + return true 46 + } 47 + 48 + // We only want the "scroll" gesture to happen whenever the pan is vertical, otherwise it will 49 + // interfere with the native swipe back gesture. 50 + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 51 + guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else { 52 + return true 53 + } 54 + 55 + let velocity = gestureRecognizer.velocity(in: self) 56 + return abs(velocity.y) > abs(velocity.x) 57 + } 58 + 59 + // This will be used to cancel the scroll animation whenever we tap inside of the header. We don't need another 60 + // recognizer for this one. 61 + override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { 62 + self.stopTimer() 63 + } 64 + 65 + // This will be used to cancel the animation whenever we press inside of the scroll view. We don't want to change 66 + // the scroll view gesture's delegate, so we add an additional recognizer to detect this. 67 + @IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void { 68 + self.stopTimer() 69 + } 70 + 71 + @IBAction func callOnPan(_ sender: UIPanGestureRecognizer) -> Void { 72 + guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else { 73 + return 74 + } 75 + 76 + let translation = sender.translation(in: self).y 77 + 78 + if sender.state == .began { 79 + if sv.contentOffset.y < 0 { 80 + sv.contentOffset.y = 0 81 + } 82 + 83 + self.initialOffset = sv.contentOffset.y 84 + } 85 + 86 + if sender.state == .changed { 87 + sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset) 88 + 89 + if sv.contentOffset.y <= -130, !didImpact { 90 + let generator = UIImpactFeedbackGenerator(style: .light) 91 + generator.impactOccurred() 92 + 93 + self.didImpact = true 94 + } 95 + } 96 + 97 + if sender.state == .ended { 98 + let velocity = sender.velocity(in: self).y 99 + self.didImpact = false 100 + 101 + if sv.contentOffset.y <= -130 { 102 + self.rctRefreshCtrl?.forwarderBeginRefreshing() 103 + return 104 + } 105 + 106 + // A check for a velocity under 250 prevents animations from occurring when they wouldn't in a normal 107 + // scroll view 108 + if abs(velocity) < 250, sv.contentOffset.y >= 0 { 109 + return 110 + } 111 + 112 + self.startDecayAnimation(translation, velocity) 113 + } 114 + } 115 + 116 + func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) { 117 + guard let sv = self.rctScrollView?.scrollView else { 118 + return 119 + } 120 + 121 + var velocity = velocity 122 + 123 + self.enableCancelGestureRecognizers() 124 + 125 + if velocity > 0 { 126 + velocity = min(velocity, 5000) 127 + } else { 128 + velocity = max(velocity, -5000) 129 + } 130 + 131 + var animTranslation = -translation 132 + self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { timer in 133 + velocity *= 0.9875 134 + animTranslation = (-velocity / 120) + animTranslation 135 + 136 + let nextOffset = self.dampenOffset(animTranslation + self.initialOffset) 137 + 138 + if nextOffset <= 0 { 139 + if self.initialOffset <= 1 { 140 + self.scrollToOffset(0) 141 + } else { 142 + sv.contentOffset.y = 0 143 + } 144 + 145 + self.stopTimer() 146 + return 147 + } else { 148 + sv.contentOffset.y = nextOffset 149 + } 150 + 151 + if abs(velocity) < 5 { 152 + self.stopTimer() 153 + } 154 + } 155 + } 156 + 157 + func dampenOffset(_ offset: CGFloat) -> CGFloat { 158 + if offset < 0 { 159 + return offset - (offset * 0.55) 160 + } 161 + 162 + return offset 163 + } 164 + 165 + func tryFindScrollView() { 166 + guard let scrollViewTag = scrollViewTag else { 167 + return 168 + } 169 + 170 + // Before we switch to a different scrollview, we always want to remove the cancel gesture recognizer. 171 + // Otherwise we might end up with duplicates when we switch back to that scrollview. 172 + self.removeCancelGestureRecognizers() 173 + 174 + self.rctScrollView = self.appContext? 175 + .findView(withTag: scrollViewTag, ofType: RCTScrollView.self) 176 + self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl 177 + 178 + self.addCancelGestureRecognizers() 179 + } 180 + 181 + func addCancelGestureRecognizers() { 182 + self.cancelGestureRecognizers?.forEach { r in 183 + self.rctScrollView?.scrollView?.addGestureRecognizer(r) 184 + } 185 + } 186 + 187 + func removeCancelGestureRecognizers() { 188 + self.cancelGestureRecognizers?.forEach { r in 189 + self.rctScrollView?.scrollView?.removeGestureRecognizer(r) 190 + } 191 + } 192 + 193 + 194 + func enableCancelGestureRecognizers() { 195 + self.cancelGestureRecognizers?.forEach { r in 196 + r.isEnabled = true 197 + } 198 + } 199 + 200 + func disableCancelGestureRecognizers() { 201 + self.cancelGestureRecognizers?.forEach { r in 202 + r.isEnabled = false 203 + } 204 + } 205 + 206 + func scrollToOffset(_ offset: Int, animated: Bool = true) -> Void { 207 + self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated) 208 + } 209 + 210 + func stopTimer() -> Void { 211 + self.disableCancelGestureRecognizers() 212 + self.animTimer?.invalidate() 213 + self.animTimer = nil 214 + } 215 + }
+6
modules/expo-scroll-forwarder/src/ExpoScrollForwarder.types.ts
···
··· 1 + import React from 'react' 2 + 3 + export interface ExpoScrollForwarderViewProps { 4 + scrollViewTag: number | null 5 + children: React.ReactNode 6 + }
+13
modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.ios.tsx
···
··· 1 + import {requireNativeViewManager} from 'expo-modules-core' 2 + import * as React from 'react' 3 + import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types' 4 + 5 + const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> = 6 + requireNativeViewManager('ExpoScrollForwarder') 7 + 8 + export function ExpoScrollForwarderView({ 9 + children, 10 + ...rest 11 + }: ExpoScrollForwarderViewProps) { 12 + return <NativeView {...rest}>{children}</NativeView> 13 + }
+7
modules/expo-scroll-forwarder/src/ExpoScrollForwarderView.tsx
···
··· 1 + import React from 'react' 2 + import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types' 3 + export function ExpoScrollForwarderView({ 4 + children, 5 + }: React.PropsWithChildren<ExpoScrollForwarderViewProps>) { 6 + return children 7 + }
+54 -4
patches/react-native+0.73.2.patch
··· 1 diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m 2 - index b09e653..d290dab 100644 3 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m 4 +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m 5 - @@ -198,6 +198,14 @@ - (void)refreshControlValueChanged 6 [self setCurrentRefreshingState:super.refreshing]; 7 _refreshingProgrammatically = NO; 8 - 9 + if (@available(iOS 17.4, *)) { 10 + if (_currentRefreshingState) { 11 + UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; ··· 16 + 17 if (_onRefresh) { 18 _onRefresh(nil); 19 - }
··· 1 + diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h 2 + index e9b330f..1ecdf0a 100644 3 + --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h 4 + +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h 5 + @@ -16,4 +16,6 @@ 6 + @property (nonatomic, copy) RCTDirectEventBlock onRefresh; 7 + @property (nonatomic, weak) UIScrollView *scrollView; 8 + 9 + +- (void)forwarderBeginRefreshing; 10 + + 11 + @end 12 diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m 13 + index b09e653..4c32b31 100644 14 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m 15 +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m 16 + @@ -198,9 +198,53 @@ - (void)refreshControlValueChanged 17 [self setCurrentRefreshingState:super.refreshing]; 18 _refreshingProgrammatically = NO; 19 + 20 + if (@available(iOS 17.4, *)) { 21 + if (_currentRefreshingState) { 22 + UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; ··· 27 + 28 if (_onRefresh) { 29 _onRefresh(nil); 30 + } 31 + } 32 + 33 + +/* 34 + + This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native 35 + + libraries to perform a refresh of a scrollview and access the refresh control's onRefresh 36 + + function. 37 + + */ 38 + +- (void)forwarderBeginRefreshing 39 + +{ 40 + + _refreshingProgrammatically = NO; 41 + + 42 + + [self sizeToFit]; 43 + + 44 + + if (!self.scrollView) { 45 + + return; 46 + + } 47 + + 48 + + UIScrollView *scrollView = (UIScrollView *)self.scrollView; 49 + + 50 + + [UIView animateWithDuration:0.3 51 + + delay:0 52 + + options:UIViewAnimationOptionBeginFromCurrentState 53 + + animations:^(void) { 54 + + // Whenever we call this method, the scrollview will always be at a position of 55 + + // -130 or less. Scrolling back to -65 simulates the default behavior of RCTRefreshControl 56 + + [scrollView setContentOffset:CGPointMake(0, -65)]; 57 + + } 58 + + completion:^(__unused BOOL finished) { 59 + + [super beginRefreshing]; 60 + + [self setCurrentRefreshingState:super.refreshing]; 61 + + 62 + + if (self->_onRefresh) { 63 + + self->_onRefresh(nil); 64 + + } 65 + + } 66 + + ]; 67 + +} 68 + + 69 + @end
+10 -2
patches/react-native+0.73.2.patch.md
··· 1 - # RefreshControl Patch 2 3 Patching `RCTRefreshControl.mm` temporarily to play an impact haptic on refresh when using iOS 17.4 or higher. Since 4 17.4, there has been a regression somewhere causing haptics to not play on iOS on refresh. Should monitor for an update 5 - in the RN repo: https://github.com/facebook/react-native/issues/43388
··· 1 + # ***This second part of this patch is load bearing, do not remove.*** 2 + 3 + ## RefreshControl Patch - iOS 17.4 Haptic Regression 4 5 Patching `RCTRefreshControl.mm` temporarily to play an impact haptic on refresh when using iOS 17.4 or higher. Since 6 17.4, there has been a regression somewhere causing haptics to not play on iOS on refresh. Should monitor for an update 7 + in the RN repo: https://github.com/facebook/react-native/issues/43388 8 + 9 + ## RefreshControl Path - ScrollForwarder 10 + 11 + Patching `RCTRefreshControl.m` and `RCTRefreshControl.h` to add a new `forwarderBeginRefreshing` method to the class. 12 + This method is used by `ExpoScrollForwarder` to initiate a refresh of the underlying `UIScrollView` from inside that 13 + module.
+25 -9
src/screens/Profile/Sections/Feed.tsx
··· 1 import React from 'react' 2 - import {View} from 'react-native' 3 import {msg, Trans} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 - import {ListRef} from 'view/com/util/List' 6 - import {Feed} from 'view/com/posts/Feed' 7 - import {EmptyState} from 'view/com/util/EmptyState' 8 import {FeedDescriptor} from '#/state/queries/post-feed' 9 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 10 - import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' 11 - import {useQueryClient} from '@tanstack/react-query' 12 import {truncateAndInvalidate} from '#/state/queries/util' 13 - import {Text} from '#/view/com/util/text/Text' 14 import {usePalette} from 'lib/hooks/usePalette' 15 - import {isNative} from '#/platform/detection' 16 import {SectionRef} from './types' 17 18 interface FeedSectionProps { ··· 21 isFocused: boolean 22 scrollElRef: ListRef 23 ignoreFilterFor?: string 24 } 25 export const ProfileFeedSection = React.forwardRef< 26 SectionRef, 27 FeedSectionProps 28 >(function FeedSectionImpl( 29 - {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor}, 30 ref, 31 ) { 32 const {_} = useLingui() ··· 49 const renderPostsEmpty = React.useCallback(() => { 50 return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} /> 51 }, [_]) 52 53 return ( 54 <View>
··· 1 import React from 'react' 2 + import {findNodeHandle, View} from 'react-native' 3 import {msg, Trans} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 + import {useQueryClient} from '@tanstack/react-query' 6 + 7 + import {isNative} from '#/platform/detection' 8 import {FeedDescriptor} from '#/state/queries/post-feed' 9 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 10 import {truncateAndInvalidate} from '#/state/queries/util' 11 import {usePalette} from 'lib/hooks/usePalette' 12 + import {Text} from '#/view/com/util/text/Text' 13 + import {Feed} from 'view/com/posts/Feed' 14 + import {EmptyState} from 'view/com/util/EmptyState' 15 + import {ListRef} from 'view/com/util/List' 16 + import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' 17 import {SectionRef} from './types' 18 19 interface FeedSectionProps { ··· 22 isFocused: boolean 23 scrollElRef: ListRef 24 ignoreFilterFor?: string 25 + setScrollViewTag: (tag: number | null) => void 26 } 27 export const ProfileFeedSection = React.forwardRef< 28 SectionRef, 29 FeedSectionProps 30 >(function FeedSectionImpl( 31 + { 32 + feed, 33 + headerHeight, 34 + isFocused, 35 + scrollElRef, 36 + ignoreFilterFor, 37 + setScrollViewTag, 38 + }, 39 ref, 40 ) { 41 const {_} = useLingui() ··· 58 const renderPostsEmpty = React.useCallback(() => { 59 return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} /> 60 }, [_]) 61 + 62 + React.useEffect(() => { 63 + if (isFocused && scrollElRef.current) { 64 + const nativeTag = findNodeHandle(scrollElRef.current) 65 + setScrollViewTag(nativeTag) 66 + } 67 + }, [isFocused, scrollElRef, setScrollViewTag]) 68 69 return ( 70 <View>
+12 -1
src/screens/Profile/Sections/Labels.tsx
··· 1 import React from 'react' 2 - import {View} from 'react-native' 3 import {useSafeAreaFrame} from 'react-native-safe-area-context' 4 import { 5 AppBskyLabelerDefs, ··· 32 moderationOpts: ModerationOpts 33 scrollElRef: ListRef 34 headerHeight: number 35 } 36 export const ProfileLabelsSection = React.forwardRef< 37 SectionRef, ··· 44 moderationOpts, 45 scrollElRef, 46 headerHeight, 47 }, 48 ref, 49 ) { ··· 62 React.useImperativeHandle(ref, () => ({ 63 scrollToTop: onScrollToTop, 64 })) 65 66 return ( 67 <CenteredView style={{flex: 1, minHeight}} sideBorders>
··· 1 import React from 'react' 2 + import {findNodeHandle, View} from 'react-native' 3 import {useSafeAreaFrame} from 'react-native-safe-area-context' 4 import { 5 AppBskyLabelerDefs, ··· 32 moderationOpts: ModerationOpts 33 scrollElRef: ListRef 34 headerHeight: number 35 + isFocused: boolean 36 + setScrollViewTag: (tag: number | null) => void 37 } 38 export const ProfileLabelsSection = React.forwardRef< 39 SectionRef, ··· 46 moderationOpts, 47 scrollElRef, 48 headerHeight, 49 + isFocused, 50 + setScrollViewTag, 51 }, 52 ref, 53 ) { ··· 66 React.useImperativeHandle(ref, () => ({ 67 scrollToTop: onScrollToTop, 68 })) 69 + 70 + React.useEffect(() => { 71 + if (isFocused && scrollElRef.current) { 72 + const nativeTag = findNodeHandle(scrollElRef.current) 73 + setScrollViewTag(nativeTag) 74 + } 75 + }, [isFocused, scrollElRef, setScrollViewTag]) 76 77 return ( 78 <CenteredView style={{flex: 1, minHeight}} sideBorders>
+29 -14
src/view/com/feeds/ProfileFeedgens.tsx
··· 1 import React from 'react' 2 - import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 3 import {useQueryClient} from '@tanstack/react-query' 4 - import {List, ListRef} from '../util/List' 5 - import {FeedSourceCardLoaded} from './FeedSourceCard' 6 - import {ErrorMessage} from '../util/error/ErrorMessage' 7 - import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' 8 - import {Text} from '../util/text/Text' 9 - import {usePalette} from 'lib/hooks/usePalette' 10 - import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens' 11 - import {logger} from '#/logger' 12 - import {Trans, msg} from '@lingui/macro' 13 import {cleanError} from '#/lib/strings/errors' 14 import {useTheme} from '#/lib/ThemeContext' 15 import {usePreferencesQuery} from '#/state/queries/preferences' 16 - import {hydrateFeedGenerator} from '#/state/queries/feed' 17 import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 18 - import {isNative} from '#/platform/detection' 19 - import {useLingui} from '@lingui/react' 20 21 const LOADING = {_reactKey: '__loading__'} 22 const EMPTY = {_reactKey: '__empty__'} ··· 34 enabled?: boolean 35 style?: StyleProp<ViewStyle> 36 testID?: string 37 } 38 39 export const ProfileFeedgens = React.forwardRef< 40 SectionRef, 41 ProfileFeedgensProps 42 >(function ProfileFeedgensImpl( 43 - {did, scrollElRef, headerOffset, enabled, style, testID}, 44 ref, 45 ) { 46 const pal = usePalette('default') ··· 168 }, 169 [error, refetch, onPressRetryLoadMore, pal, preferences, _], 170 ) 171 172 return ( 173 <View testID={testID} style={style}>
··· 1 import React from 'react' 2 + import { 3 + findNodeHandle, 4 + StyleProp, 5 + StyleSheet, 6 + View, 7 + ViewStyle, 8 + } from 'react-native' 9 + import {msg, Trans} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 11 import {useQueryClient} from '@tanstack/react-query' 12 + 13 import {cleanError} from '#/lib/strings/errors' 14 import {useTheme} from '#/lib/ThemeContext' 15 + import {logger} from '#/logger' 16 + import {isNative} from '#/platform/detection' 17 + import {hydrateFeedGenerator} from '#/state/queries/feed' 18 import {usePreferencesQuery} from '#/state/queries/preferences' 19 + import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens' 20 + import {usePalette} from 'lib/hooks/usePalette' 21 import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 22 + import {ErrorMessage} from '../util/error/ErrorMessage' 23 + import {List, ListRef} from '../util/List' 24 + import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' 25 + import {Text} from '../util/text/Text' 26 + import {FeedSourceCardLoaded} from './FeedSourceCard' 27 28 const LOADING = {_reactKey: '__loading__'} 29 const EMPTY = {_reactKey: '__empty__'} ··· 41 enabled?: boolean 42 style?: StyleProp<ViewStyle> 43 testID?: string 44 + setScrollViewTag: (tag: number | null) => void 45 } 46 47 export const ProfileFeedgens = React.forwardRef< 48 SectionRef, 49 ProfileFeedgensProps 50 >(function ProfileFeedgensImpl( 51 + {did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag}, 52 ref, 53 ) { 54 const pal = usePalette('default') ··· 176 }, 177 [error, refetch, onPressRetryLoadMore, pal, preferences, _], 178 ) 179 + 180 + React.useEffect(() => { 181 + if (enabled && scrollElRef.current) { 182 + const nativeTag = findNodeHandle(scrollElRef.current) 183 + setScrollViewTag(nativeTag) 184 + } 185 + }, [enabled, scrollElRef, setScrollViewTag]) 186 187 return ( 188 <View testID={testID} style={style}>
+29 -14
src/view/com/lists/ProfileLists.tsx
··· 1 import React from 'react' 2 - import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 3 import {useQueryClient} from '@tanstack/react-query' 4 - import {List, ListRef} from '../util/List' 5 - import {ListCard} from './ListCard' 6 - import {ErrorMessage} from '../util/error/ErrorMessage' 7 - import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' 8 - import {Text} from '../util/text/Text' 9 - import {useAnalytics} from 'lib/analytics/analytics' 10 - import {usePalette} from 'lib/hooks/usePalette' 11 - import {useProfileListsQuery, RQKEY} from '#/state/queries/profile-lists' 12 - import {logger} from '#/logger' 13 - import {Trans, msg} from '@lingui/macro' 14 import {cleanError} from '#/lib/strings/errors' 15 import {useTheme} from '#/lib/ThemeContext' 16 - import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 17 import {isNative} from '#/platform/detection' 18 - import {useLingui} from '@lingui/react' 19 20 const LOADING = {_reactKey: '__loading__'} 21 const EMPTY = {_reactKey: '__empty__'} ··· 33 enabled?: boolean 34 style?: StyleProp<ViewStyle> 35 testID?: string 36 } 37 38 export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( 39 function ProfileListsImpl( 40 - {did, scrollElRef, headerOffset, enabled, style, testID}, 41 ref, 42 ) { 43 const pal = usePalette('default') ··· 170 }, 171 [error, refetch, onPressRetryLoadMore, pal, _], 172 ) 173 174 return ( 175 <View testID={testID} style={style}>
··· 1 import React from 'react' 2 + import { 3 + findNodeHandle, 4 + StyleProp, 5 + StyleSheet, 6 + View, 7 + ViewStyle, 8 + } from 'react-native' 9 + import {msg, Trans} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 11 import {useQueryClient} from '@tanstack/react-query' 12 + 13 import {cleanError} from '#/lib/strings/errors' 14 import {useTheme} from '#/lib/ThemeContext' 15 + import {logger} from '#/logger' 16 import {isNative} from '#/platform/detection' 17 + import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists' 18 + import {useAnalytics} from 'lib/analytics/analytics' 19 + import {usePalette} from 'lib/hooks/usePalette' 20 + import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 21 + import {ErrorMessage} from '../util/error/ErrorMessage' 22 + import {List, ListRef} from '../util/List' 23 + import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' 24 + import {Text} from '../util/text/Text' 25 + import {ListCard} from './ListCard' 26 27 const LOADING = {_reactKey: '__loading__'} 28 const EMPTY = {_reactKey: '__empty__'} ··· 40 enabled?: boolean 41 style?: StyleProp<ViewStyle> 42 testID?: string 43 + setScrollViewTag: (tag: number | null) => void 44 } 45 46 export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( 47 function ProfileListsImpl( 48 + {did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag}, 49 ref, 50 ) { 51 const pal = usePalette('default') ··· 178 }, 179 [error, refetch, onPressRetryLoadMore, pal, _], 180 ) 181 + 182 + React.useEffect(() => { 183 + if (enabled && scrollElRef.current) { 184 + const nativeTag = findNodeHandle(scrollElRef.current) 185 + setScrollViewTag(nativeTag) 186 + } 187 + }, [enabled, scrollElRef, setScrollViewTag]) 188 189 return ( 190 <View testID={testID} style={style}>
+49 -20
src/view/screens/Profile.tsx
··· 12 import {useQueryClient} from '@tanstack/react-query' 13 14 import {cleanError} from '#/lib/strings/errors' 15 - import {isInvalidHandle} from '#/lib/strings/handles' 16 import {useProfileShadow} from '#/state/cache/profile-shadow' 17 - import {listenSoftReset} from '#/state/events' 18 import {useLabelerInfoQuery} from '#/state/queries/labeler' 19 import {resetProfilePostsQueries} from '#/state/queries/post-feed' 20 import {useModerationOpts} from '#/state/queries/preferences' ··· 27 import {useSetTitle} from 'lib/hooks/useSetTitle' 28 import {ComposeIcon2} from 'lib/icons' 29 import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' 30 import {combinedDisplayName} from 'lib/strings/display-names' 31 import {colors, s} from 'lib/styles' 32 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' 33 import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' 34 import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' 35 import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' 36 import {ScreenHider} from '#/components/moderation/ScreenHider' 37 import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' 38 import {ProfileLists} from '../com/lists/ProfileLists' 39 import {ErrorScreen} from '../com/util/error/ErrorScreen' ··· 141 const setMinimalShellMode = useSetMinimalShellMode() 142 const {openComposer} = useComposerControls() 143 const {screen, track} = useAnalytics() 144 const { 145 data: labelerInfo, 146 error: labelerError, ··· 152 const [currentPage, setCurrentPage] = React.useState(0) 153 const {_} = useLingui() 154 const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() 155 const postsSectionRef = React.useRef<SectionRef>(null) 156 const repliesSectionRef = React.useRef<SectionRef>(null) 157 const mediaSectionRef = React.useRef<SectionRef>(null) ··· 297 openComposer({mention}) 298 }, [openComposer, currentAccount, track, profile]) 299 300 - const onPageSelected = React.useCallback( 301 - (i: number) => { 302 - setCurrentPage(i) 303 - }, 304 - [setCurrentPage], 305 - ) 306 307 const onCurrentPageSelected = React.useCallback( 308 (index: number) => { ··· 315 // = 316 317 const renderHeader = React.useCallback(() => { 318 - return ( 319 - <ProfileHeader 320 - profile={profile} 321 - labeler={labelerInfo} 322 - descriptionRT={hasDescription ? descriptionRT : null} 323 - moderationOpts={moderationOpts} 324 - hideBackButton={hideBackButton} 325 - isPlaceholderProfile={showPlaceholder} 326 - /> 327 - ) 328 }, [ 329 profile, 330 labelerInfo, 331 descriptionRT, 332 - hasDescription, 333 moderationOpts, 334 hideBackButton, 335 showPlaceholder, ··· 349 onCurrentPageSelected={onCurrentPageSelected} 350 renderHeader={renderHeader}> 351 {showFiltersTab 352 - ? ({headerHeight, scrollElRef}) => ( 353 <ProfileLabelsSection 354 ref={labelsSectionRef} 355 labelerInfo={labelerInfo} ··· 358 moderationOpts={moderationOpts} 359 scrollElRef={scrollElRef as ListRef} 360 headerHeight={headerHeight} 361 /> 362 ) 363 : null} ··· 369 scrollElRef={scrollElRef as ListRef} 370 headerOffset={headerHeight} 371 enabled={isFocused} 372 /> 373 ) 374 : null} ··· 381 isFocused={isFocused} 382 scrollElRef={scrollElRef as ListRef} 383 ignoreFilterFor={profile.did} 384 /> 385 ) 386 : null} ··· 393 isFocused={isFocused} 394 scrollElRef={scrollElRef as ListRef} 395 ignoreFilterFor={profile.did} 396 /> 397 ) 398 : null} ··· 405 isFocused={isFocused} 406 scrollElRef={scrollElRef as ListRef} 407 ignoreFilterFor={profile.did} 408 /> 409 ) 410 : null} ··· 417 isFocused={isFocused} 418 scrollElRef={scrollElRef as ListRef} 419 ignoreFilterFor={profile.did} 420 /> 421 ) 422 : null} ··· 428 scrollElRef={scrollElRef as ListRef} 429 headerOffset={headerHeight} 430 enabled={isFocused} 431 /> 432 ) 433 : null} ··· 439 scrollElRef={scrollElRef as ListRef} 440 headerOffset={headerHeight} 441 enabled={isFocused} 442 /> 443 ) 444 : null}
··· 12 import {useQueryClient} from '@tanstack/react-query' 13 14 import {cleanError} from '#/lib/strings/errors' 15 import {useProfileShadow} from '#/state/cache/profile-shadow' 16 import {useLabelerInfoQuery} from '#/state/queries/labeler' 17 import {resetProfilePostsQueries} from '#/state/queries/post-feed' 18 import {useModerationOpts} from '#/state/queries/preferences' ··· 25 import {useSetTitle} from 'lib/hooks/useSetTitle' 26 import {ComposeIcon2} from 'lib/icons' 27 import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' 28 + import {useGate} from 'lib/statsig/statsig' 29 import {combinedDisplayName} from 'lib/strings/display-names' 30 + import {isInvalidHandle} from 'lib/strings/handles' 31 import {colors, s} from 'lib/styles' 32 + import {listenSoftReset} from 'state/events' 33 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' 34 import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' 35 import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' 36 import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' 37 import {ScreenHider} from '#/components/moderation/ScreenHider' 38 + import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder' 39 import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' 40 import {ProfileLists} from '../com/lists/ProfileLists' 41 import {ErrorScreen} from '../com/util/error/ErrorScreen' ··· 143 const setMinimalShellMode = useSetMinimalShellMode() 144 const {openComposer} = useComposerControls() 145 const {screen, track} = useAnalytics() 146 + const shouldUseScrollableHeader = useGate('new_profile_scroll_component') 147 const { 148 data: labelerInfo, 149 error: labelerError, ··· 155 const [currentPage, setCurrentPage] = React.useState(0) 156 const {_} = useLingui() 157 const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() 158 + 159 + const [scrollViewTag, setScrollViewTag] = React.useState<number | null>(null) 160 + 161 const postsSectionRef = React.useRef<SectionRef>(null) 162 const repliesSectionRef = React.useRef<SectionRef>(null) 163 const mediaSectionRef = React.useRef<SectionRef>(null) ··· 303 openComposer({mention}) 304 }, [openComposer, currentAccount, track, profile]) 305 306 + const onPageSelected = React.useCallback((i: number) => { 307 + setCurrentPage(i) 308 + }, []) 309 310 const onCurrentPageSelected = React.useCallback( 311 (index: number) => { ··· 318 // = 319 320 const renderHeader = React.useCallback(() => { 321 + if (shouldUseScrollableHeader) { 322 + return ( 323 + <ExpoScrollForwarderView scrollViewTag={scrollViewTag}> 324 + <ProfileHeader 325 + profile={profile} 326 + labeler={labelerInfo} 327 + descriptionRT={hasDescription ? descriptionRT : null} 328 + moderationOpts={moderationOpts} 329 + hideBackButton={hideBackButton} 330 + isPlaceholderProfile={showPlaceholder} 331 + /> 332 + </ExpoScrollForwarderView> 333 + ) 334 + } else { 335 + return ( 336 + <ProfileHeader 337 + profile={profile} 338 + labeler={labelerInfo} 339 + descriptionRT={hasDescription ? descriptionRT : null} 340 + moderationOpts={moderationOpts} 341 + hideBackButton={hideBackButton} 342 + isPlaceholderProfile={showPlaceholder} 343 + /> 344 + ) 345 + } 346 }, [ 347 + shouldUseScrollableHeader, 348 + scrollViewTag, 349 profile, 350 labelerInfo, 351 + hasDescription, 352 descriptionRT, 353 moderationOpts, 354 hideBackButton, 355 showPlaceholder, ··· 369 onCurrentPageSelected={onCurrentPageSelected} 370 renderHeader={renderHeader}> 371 {showFiltersTab 372 + ? ({headerHeight, isFocused, scrollElRef}) => ( 373 <ProfileLabelsSection 374 ref={labelsSectionRef} 375 labelerInfo={labelerInfo} ··· 378 moderationOpts={moderationOpts} 379 scrollElRef={scrollElRef as ListRef} 380 headerHeight={headerHeight} 381 + isFocused={isFocused} 382 + setScrollViewTag={setScrollViewTag} 383 /> 384 ) 385 : null} ··· 391 scrollElRef={scrollElRef as ListRef} 392 headerOffset={headerHeight} 393 enabled={isFocused} 394 + setScrollViewTag={setScrollViewTag} 395 /> 396 ) 397 : null} ··· 404 isFocused={isFocused} 405 scrollElRef={scrollElRef as ListRef} 406 ignoreFilterFor={profile.did} 407 + setScrollViewTag={setScrollViewTag} 408 /> 409 ) 410 : null} ··· 417 isFocused={isFocused} 418 scrollElRef={scrollElRef as ListRef} 419 ignoreFilterFor={profile.did} 420 + setScrollViewTag={setScrollViewTag} 421 /> 422 ) 423 : null} ··· 430 isFocused={isFocused} 431 scrollElRef={scrollElRef as ListRef} 432 ignoreFilterFor={profile.did} 433 + setScrollViewTag={setScrollViewTag} 434 /> 435 ) 436 : null} ··· 443 isFocused={isFocused} 444 scrollElRef={scrollElRef as ListRef} 445 ignoreFilterFor={profile.did} 446 + setScrollViewTag={setScrollViewTag} 447 /> 448 ) 449 : null} ··· 455 scrollElRef={scrollElRef as ListRef} 456 headerOffset={headerHeight} 457 enabled={isFocused} 458 + setScrollViewTag={setScrollViewTag} 459 /> 460 ) 461 : null} ··· 467 scrollElRef={scrollElRef as ListRef} 468 headerOffset={headerHeight} 469 enabled={isFocused} 470 + setScrollViewTag={setScrollViewTag} 471 /> 472 ) 473 : null}