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

+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.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 1 12 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 13 + index b09e653..4c32b31 100644 3 14 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m 4 15 +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m 5 - @@ -198,6 +198,14 @@ - (void)refreshControlValueChanged 16 + @@ -198,9 +198,53 @@ - (void)refreshControlValueChanged 6 17 [self setCurrentRefreshingState:super.refreshing]; 7 18 _refreshingProgrammatically = NO; 8 - 19 + 9 20 + if (@available(iOS 17.4, *)) { 10 21 + if (_currentRefreshingState) { 11 22 + UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; ··· 16 27 + 17 28 if (_onRefresh) { 18 29 _onRefresh(nil); 19 - } 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 1 + # ***This second part of this patch is load bearing, do not remove.*** 2 + 3 + ## RefreshControl Patch - iOS 17.4 Haptic Regression 2 4 3 5 Patching `RCTRefreshControl.mm` temporarily to play an impact haptic on refresh when using iOS 17.4 or higher. Since 4 6 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 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 1 import React from 'react' 2 - import {View} from 'react-native' 2 + import {findNodeHandle, View} from 'react-native' 3 3 import {msg, Trans} from '@lingui/macro' 4 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' 5 + import {useQueryClient} from '@tanstack/react-query' 6 + 7 + import {isNative} from '#/platform/detection' 8 8 import {FeedDescriptor} from '#/state/queries/post-feed' 9 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 10 import {truncateAndInvalidate} from '#/state/queries/util' 13 - import {Text} from '#/view/com/util/text/Text' 14 11 import {usePalette} from 'lib/hooks/usePalette' 15 - import {isNative} from '#/platform/detection' 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' 16 17 import {SectionRef} from './types' 17 18 18 19 interface FeedSectionProps { ··· 21 22 isFocused: boolean 22 23 scrollElRef: ListRef 23 24 ignoreFilterFor?: string 25 + setScrollViewTag: (tag: number | null) => void 24 26 } 25 27 export const ProfileFeedSection = React.forwardRef< 26 28 SectionRef, 27 29 FeedSectionProps 28 30 >(function FeedSectionImpl( 29 - {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor}, 31 + { 32 + feed, 33 + headerHeight, 34 + isFocused, 35 + scrollElRef, 36 + ignoreFilterFor, 37 + setScrollViewTag, 38 + }, 30 39 ref, 31 40 ) { 32 41 const {_} = useLingui() ··· 49 58 const renderPostsEmpty = React.useCallback(() => { 50 59 return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} /> 51 60 }, [_]) 61 + 62 + React.useEffect(() => { 63 + if (isFocused && scrollElRef.current) { 64 + const nativeTag = findNodeHandle(scrollElRef.current) 65 + setScrollViewTag(nativeTag) 66 + } 67 + }, [isFocused, scrollElRef, setScrollViewTag]) 52 68 53 69 return ( 54 70 <View>
+12 -1
src/screens/Profile/Sections/Labels.tsx
··· 1 1 import React from 'react' 2 - import {View} from 'react-native' 2 + import {findNodeHandle, View} from 'react-native' 3 3 import {useSafeAreaFrame} from 'react-native-safe-area-context' 4 4 import { 5 5 AppBskyLabelerDefs, ··· 32 32 moderationOpts: ModerationOpts 33 33 scrollElRef: ListRef 34 34 headerHeight: number 35 + isFocused: boolean 36 + setScrollViewTag: (tag: number | null) => void 35 37 } 36 38 export const ProfileLabelsSection = React.forwardRef< 37 39 SectionRef, ··· 44 46 moderationOpts, 45 47 scrollElRef, 46 48 headerHeight, 49 + isFocused, 50 + setScrollViewTag, 47 51 }, 48 52 ref, 49 53 ) { ··· 62 66 React.useImperativeHandle(ref, () => ({ 63 67 scrollToTop: onScrollToTop, 64 68 })) 69 + 70 + React.useEffect(() => { 71 + if (isFocused && scrollElRef.current) { 72 + const nativeTag = findNodeHandle(scrollElRef.current) 73 + setScrollViewTag(nativeTag) 74 + } 75 + }, [isFocused, scrollElRef, setScrollViewTag]) 65 76 66 77 return ( 67 78 <CenteredView style={{flex: 1, minHeight}} sideBorders>
+29 -14
src/view/com/feeds/ProfileFeedgens.tsx
··· 1 1 import React from 'react' 2 - import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 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' 3 11 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' 12 + 13 13 import {cleanError} from '#/lib/strings/errors' 14 14 import {useTheme} from '#/lib/ThemeContext' 15 + import {logger} from '#/logger' 16 + import {isNative} from '#/platform/detection' 17 + import {hydrateFeedGenerator} from '#/state/queries/feed' 15 18 import {usePreferencesQuery} from '#/state/queries/preferences' 16 - import {hydrateFeedGenerator} from '#/state/queries/feed' 19 + import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens' 20 + import {usePalette} from 'lib/hooks/usePalette' 17 21 import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 18 - import {isNative} from '#/platform/detection' 19 - import {useLingui} from '@lingui/react' 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' 20 27 21 28 const LOADING = {_reactKey: '__loading__'} 22 29 const EMPTY = {_reactKey: '__empty__'} ··· 34 41 enabled?: boolean 35 42 style?: StyleProp<ViewStyle> 36 43 testID?: string 44 + setScrollViewTag: (tag: number | null) => void 37 45 } 38 46 39 47 export const ProfileFeedgens = React.forwardRef< 40 48 SectionRef, 41 49 ProfileFeedgensProps 42 50 >(function ProfileFeedgensImpl( 43 - {did, scrollElRef, headerOffset, enabled, style, testID}, 51 + {did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag}, 44 52 ref, 45 53 ) { 46 54 const pal = usePalette('default') ··· 168 176 }, 169 177 [error, refetch, onPressRetryLoadMore, pal, preferences, _], 170 178 ) 179 + 180 + React.useEffect(() => { 181 + if (enabled && scrollElRef.current) { 182 + const nativeTag = findNodeHandle(scrollElRef.current) 183 + setScrollViewTag(nativeTag) 184 + } 185 + }, [enabled, scrollElRef, setScrollViewTag]) 171 186 172 187 return ( 173 188 <View testID={testID} style={style}>
+29 -14
src/view/com/lists/ProfileLists.tsx
··· 1 1 import React from 'react' 2 - import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 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' 3 11 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' 12 + 14 13 import {cleanError} from '#/lib/strings/errors' 15 14 import {useTheme} from '#/lib/ThemeContext' 16 - import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 15 + import {logger} from '#/logger' 17 16 import {isNative} from '#/platform/detection' 18 - import {useLingui} from '@lingui/react' 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' 19 26 20 27 const LOADING = {_reactKey: '__loading__'} 21 28 const EMPTY = {_reactKey: '__empty__'} ··· 33 40 enabled?: boolean 34 41 style?: StyleProp<ViewStyle> 35 42 testID?: string 43 + setScrollViewTag: (tag: number | null) => void 36 44 } 37 45 38 46 export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( 39 47 function ProfileListsImpl( 40 - {did, scrollElRef, headerOffset, enabled, style, testID}, 48 + {did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag}, 41 49 ref, 42 50 ) { 43 51 const pal = usePalette('default') ··· 170 178 }, 171 179 [error, refetch, onPressRetryLoadMore, pal, _], 172 180 ) 181 + 182 + React.useEffect(() => { 183 + if (enabled && scrollElRef.current) { 184 + const nativeTag = findNodeHandle(scrollElRef.current) 185 + setScrollViewTag(nativeTag) 186 + } 187 + }, [enabled, scrollElRef, setScrollViewTag]) 173 188 174 189 return ( 175 190 <View testID={testID} style={style}>
+49 -20
src/view/screens/Profile.tsx
··· 12 12 import {useQueryClient} from '@tanstack/react-query' 13 13 14 14 import {cleanError} from '#/lib/strings/errors' 15 - import {isInvalidHandle} from '#/lib/strings/handles' 16 15 import {useProfileShadow} from '#/state/cache/profile-shadow' 17 - import {listenSoftReset} from '#/state/events' 18 16 import {useLabelerInfoQuery} from '#/state/queries/labeler' 19 17 import {resetProfilePostsQueries} from '#/state/queries/post-feed' 20 18 import {useModerationOpts} from '#/state/queries/preferences' ··· 27 25 import {useSetTitle} from 'lib/hooks/useSetTitle' 28 26 import {ComposeIcon2} from 'lib/icons' 29 27 import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' 28 + import {useGate} from 'lib/statsig/statsig' 30 29 import {combinedDisplayName} from 'lib/strings/display-names' 30 + import {isInvalidHandle} from 'lib/strings/handles' 31 31 import {colors, s} from 'lib/styles' 32 + import {listenSoftReset} from 'state/events' 32 33 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' 33 34 import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' 34 35 import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' 35 36 import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' 36 37 import {ScreenHider} from '#/components/moderation/ScreenHider' 38 + import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder' 37 39 import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' 38 40 import {ProfileLists} from '../com/lists/ProfileLists' 39 41 import {ErrorScreen} from '../com/util/error/ErrorScreen' ··· 141 143 const setMinimalShellMode = useSetMinimalShellMode() 142 144 const {openComposer} = useComposerControls() 143 145 const {screen, track} = useAnalytics() 146 + const shouldUseScrollableHeader = useGate('new_profile_scroll_component') 144 147 const { 145 148 data: labelerInfo, 146 149 error: labelerError, ··· 152 155 const [currentPage, setCurrentPage] = React.useState(0) 153 156 const {_} = useLingui() 154 157 const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() 158 + 159 + const [scrollViewTag, setScrollViewTag] = React.useState<number | null>(null) 160 + 155 161 const postsSectionRef = React.useRef<SectionRef>(null) 156 162 const repliesSectionRef = React.useRef<SectionRef>(null) 157 163 const mediaSectionRef = React.useRef<SectionRef>(null) ··· 297 303 openComposer({mention}) 298 304 }, [openComposer, currentAccount, track, profile]) 299 305 300 - const onPageSelected = React.useCallback( 301 - (i: number) => { 302 - setCurrentPage(i) 303 - }, 304 - [setCurrentPage], 305 - ) 306 + const onPageSelected = React.useCallback((i: number) => { 307 + setCurrentPage(i) 308 + }, []) 306 309 307 310 const onCurrentPageSelected = React.useCallback( 308 311 (index: number) => { ··· 315 318 // = 316 319 317 320 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 - ) 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 + } 328 346 }, [ 347 + shouldUseScrollableHeader, 348 + scrollViewTag, 329 349 profile, 330 350 labelerInfo, 351 + hasDescription, 331 352 descriptionRT, 332 - hasDescription, 333 353 moderationOpts, 334 354 hideBackButton, 335 355 showPlaceholder, ··· 349 369 onCurrentPageSelected={onCurrentPageSelected} 350 370 renderHeader={renderHeader}> 351 371 {showFiltersTab 352 - ? ({headerHeight, scrollElRef}) => ( 372 + ? ({headerHeight, isFocused, scrollElRef}) => ( 353 373 <ProfileLabelsSection 354 374 ref={labelsSectionRef} 355 375 labelerInfo={labelerInfo} ··· 358 378 moderationOpts={moderationOpts} 359 379 scrollElRef={scrollElRef as ListRef} 360 380 headerHeight={headerHeight} 381 + isFocused={isFocused} 382 + setScrollViewTag={setScrollViewTag} 361 383 /> 362 384 ) 363 385 : null} ··· 369 391 scrollElRef={scrollElRef as ListRef} 370 392 headerOffset={headerHeight} 371 393 enabled={isFocused} 394 + setScrollViewTag={setScrollViewTag} 372 395 /> 373 396 ) 374 397 : null} ··· 381 404 isFocused={isFocused} 382 405 scrollElRef={scrollElRef as ListRef} 383 406 ignoreFilterFor={profile.did} 407 + setScrollViewTag={setScrollViewTag} 384 408 /> 385 409 ) 386 410 : null} ··· 393 417 isFocused={isFocused} 394 418 scrollElRef={scrollElRef as ListRef} 395 419 ignoreFilterFor={profile.did} 420 + setScrollViewTag={setScrollViewTag} 396 421 /> 397 422 ) 398 423 : null} ··· 405 430 isFocused={isFocused} 406 431 scrollElRef={scrollElRef as ListRef} 407 432 ignoreFilterFor={profile.did} 433 + setScrollViewTag={setScrollViewTag} 408 434 /> 409 435 ) 410 436 : null} ··· 417 443 isFocused={isFocused} 418 444 scrollElRef={scrollElRef as ListRef} 419 445 ignoreFilterFor={profile.did} 446 + setScrollViewTag={setScrollViewTag} 420 447 /> 421 448 ) 422 449 : null} ··· 428 455 scrollElRef={scrollElRef as ListRef} 429 456 headerOffset={headerHeight} 430 457 enabled={isFocused} 458 + setScrollViewTag={setScrollViewTag} 431 459 /> 432 460 ) 433 461 : null} ··· 439 467 scrollElRef={scrollElRef as ListRef} 440 468 headerOffset={headerHeight} 441 469 enabled={isFocused} 470 + setScrollViewTag={setScrollViewTag} 442 471 /> 443 472 ) 444 473 : null}