Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Android fixes (#515)

* Fix profile screen performance on android and remove dead code

* Correctly handle android hardware back btn

* Fix EditProfile modal for android

* Fix lint

authored by

Paul Frazee and committed by
GitHub
d35f7c1f eb6b36be

+273 -594
+2
src/App.native.tsx
··· 13 13 import {Shell} from './view/shell' 14 14 import * as notifee from 'lib/notifee' 15 15 import * as analytics from 'lib/analytics' 16 + import * as backHandler from 'lib/routes/back-handler' 16 17 import * as Toast from './view/com/util/Toast' 17 18 import {handleLink} from './Navigation' 18 19 ··· 28 29 setRootStore(store) 29 30 analytics.init(store) 30 31 notifee.init(store) 32 + backHandler.init(store) 31 33 SplashScreen.hideAsync() 32 34 Linking.getInitialURL().then((url: string | null) => { 33 35 if (url) {
+11
src/lib/routes/back-handler.ts
··· 1 + import {BackHandler} from 'react-native' 2 + import {RootStoreModel} from 'state/index' 3 + 4 + export function onBack(cb: () => boolean): () => void { 5 + const subscription = BackHandler.addEventListener('hardwareBackPress', cb) 6 + return () => subscription.remove() 7 + } 8 + 9 + export function init(store: RootStoreModel) { 10 + onBack(() => store.shell.closeAnyActiveElement()) 11 + }
+24
src/state/models/ui/shell.ts
··· 194 194 this.minimalShellMode = v 195 195 } 196 196 197 + /** 198 + * returns true if something was closed 199 + * (used by the android hardware back btn) 200 + */ 201 + closeAnyActiveElement(): boolean { 202 + if (this.isLightboxActive) { 203 + this.closeLightbox() 204 + return true 205 + } 206 + if (this.isModalActive) { 207 + this.closeModal() 208 + return true 209 + } 210 + if (this.isComposerActive) { 211 + this.closeComposer() 212 + return true 213 + } 214 + if (this.isDrawerOpen) { 215 + this.closeDrawer() 216 + return true 217 + } 218 + return false 219 + } 220 + 197 221 openDrawer() { 198 222 this.isDrawerOpen = true 199 223 }
+1 -2
src/view/com/lightbox/Lightbox.tsx
··· 1 1 import React from 'react' 2 - import {View} from 'react-native' 3 2 import {observer} from 'mobx-react-lite' 4 3 import ImageView from './ImageViewing' 5 4 import {useStores} from 'state/index' ··· 48 47 /> 49 48 ) 50 49 } else { 51 - return <View /> 50 + return null 52 51 } 53 52 })
+108 -85
src/view/com/modals/EditProfile.tsx
··· 1 - import React, {useState} from 'react' 1 + import React, {useState, useCallback} from 'react' 2 2 import * as Toast from '../util/Toast' 3 3 import { 4 4 ActivityIndicator, 5 + KeyboardAvoidingView, 6 + ScrollView, 5 7 StyleSheet, 8 + TextInput, 6 9 TouchableOpacity, 7 10 View, 8 11 } from 'react-native' 9 12 import LinearGradient from 'react-native-linear-gradient' 10 - import {ScrollView, TextInput} from './util' 11 13 import {Image as RNImage} from 'react-native-image-crop-picker' 12 14 import {Text} from '../util/text/Text' 13 15 import {ErrorMessage} from '../util/error/ErrorMessage' ··· 24 26 import {useAnalytics} from 'lib/analytics' 25 27 import {cleanError, isNetworkError} from 'lib/strings/errors' 26 28 27 - export const snapPoints = ['80%'] 29 + export const snapPoints = ['fullscreen'] 28 30 29 31 export function Component({ 30 32 profileView, ··· 61 63 const onPressCancel = () => { 62 64 store.shell.closeModal() 63 65 } 64 - const onSelectNewAvatar = async (img: RNImage | null) => { 65 - track('EditProfile:AvatarSelected') 66 - try { 67 - // if img is null, user selected "remove avatar" 66 + const onSelectNewAvatar = useCallback( 67 + async (img: RNImage | null) => { 68 68 if (!img) { 69 69 setNewUserAvatar(null) 70 70 setUserAvatar(null) 71 71 return 72 72 } 73 - const finalImg = await compressIfNeeded(img, 1000000) 74 - setNewUserAvatar(finalImg) 75 - setUserAvatar(finalImg.path) 76 - } catch (e: any) { 77 - setError(cleanError(e)) 78 - } 79 - } 80 - const onSelectNewBanner = async (img: RNImage | null) => { 81 - if (!img) { 82 - setNewUserBanner(null) 83 - setUserBanner(null) 84 - return 85 - } 86 - track('EditProfile:BannerSelected') 87 - try { 88 - const finalImg = await compressIfNeeded(img, 1000000) 89 - setNewUserBanner(finalImg) 90 - setUserBanner(finalImg.path) 91 - } catch (e: any) { 92 - setError(cleanError(e)) 93 - } 94 - } 95 - const onPressSave = async () => { 73 + track('EditProfile:AvatarSelected') 74 + try { 75 + const finalImg = await compressIfNeeded(img, 1000000) 76 + setNewUserAvatar(finalImg) 77 + setUserAvatar(finalImg.path) 78 + } catch (e: any) { 79 + setError(cleanError(e)) 80 + } 81 + }, 82 + [track, setNewUserAvatar, setUserAvatar, setError], 83 + ) 84 + const onSelectNewBanner = useCallback( 85 + async (img: RNImage | null) => { 86 + if (!img) { 87 + setNewUserBanner(null) 88 + setUserBanner(null) 89 + return 90 + } 91 + track('EditProfile:BannerSelected') 92 + try { 93 + const finalImg = await compressIfNeeded(img, 1000000) 94 + setNewUserBanner(finalImg) 95 + setUserBanner(finalImg.path) 96 + } catch (e: any) { 97 + setError(cleanError(e)) 98 + } 99 + }, 100 + [track, setNewUserBanner, setUserBanner, setError], 101 + ) 102 + const onPressSave = useCallback(async () => { 96 103 track('EditProfile:Save') 97 104 setProcessing(true) 98 105 if (error) { ··· 120 127 } 121 128 } 122 129 setProcessing(false) 123 - } 130 + }, [ 131 + track, 132 + setProcessing, 133 + setError, 134 + error, 135 + profileView, 136 + onUpdate, 137 + store, 138 + displayName, 139 + description, 140 + newUserAvatar, 141 + newUserBanner, 142 + ]) 124 143 125 144 return ( 126 - <View style={[s.flex1, pal.view]} testID="editProfileModal"> 127 - <ScrollView style={styles.inner}> 145 + <KeyboardAvoidingView behavior="height"> 146 + <ScrollView style={[pal.view]} testID="editProfileModal"> 128 147 <Text style={[styles.title, pal.text]}>Edit my profile</Text> 129 148 <View style={styles.photos}> 130 149 <UserBanner ··· 144 163 <ErrorMessage message={error} /> 145 164 </View> 146 165 )} 147 - <View> 148 - <Text style={[styles.label, pal.text]}>Display Name</Text> 149 - <TextInput 150 - testID="editProfileDisplayNameInput" 151 - style={[styles.textInput, pal.border, pal.text]} 152 - placeholder="e.g. Alice Roberts" 153 - placeholderTextColor={colors.gray4} 154 - value={displayName} 155 - onChangeText={v => setDisplayName(enforceLen(v, MAX_DISPLAY_NAME))} 156 - /> 157 - </View> 158 - <View style={s.pb10}> 159 - <Text style={[styles.label, pal.text]}>Description</Text> 160 - <TextInput 161 - testID="editProfileDescriptionInput" 162 - style={[styles.textArea, pal.border, pal.text]} 163 - placeholder="e.g. Artist, dog-lover, and memelord." 164 - placeholderTextColor={colors.gray4} 165 - keyboardAppearance={theme.colorScheme} 166 - multiline 167 - value={description} 168 - onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} 169 - /> 170 - </View> 171 - {isProcessing ? ( 172 - <View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}> 173 - <ActivityIndicator /> 166 + <View style={styles.form}> 167 + <View> 168 + <Text style={[styles.label, pal.text]}>Display Name</Text> 169 + <TextInput 170 + testID="editProfileDisplayNameInput" 171 + style={[styles.textInput, pal.border, pal.text]} 172 + placeholder="e.g. Alice Roberts" 173 + placeholderTextColor={colors.gray4} 174 + value={displayName} 175 + onChangeText={v => 176 + setDisplayName(enforceLen(v, MAX_DISPLAY_NAME)) 177 + } 178 + /> 174 179 </View> 175 - ) : ( 180 + <View style={s.pb10}> 181 + <Text style={[styles.label, pal.text]}>Description</Text> 182 + <TextInput 183 + testID="editProfileDescriptionInput" 184 + style={[styles.textArea, pal.border, pal.text]} 185 + placeholder="e.g. Artist, dog-lover, and memelord." 186 + placeholderTextColor={colors.gray4} 187 + keyboardAppearance={theme.colorScheme} 188 + multiline 189 + value={description} 190 + onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} 191 + /> 192 + </View> 193 + {isProcessing ? ( 194 + <View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}> 195 + <ActivityIndicator /> 196 + </View> 197 + ) : ( 198 + <TouchableOpacity 199 + testID="editProfileSaveBtn" 200 + style={s.mt10} 201 + onPress={onPressSave}> 202 + <LinearGradient 203 + colors={[gradients.blueLight.start, gradients.blueLight.end]} 204 + start={{x: 0, y: 0}} 205 + end={{x: 1, y: 1}} 206 + style={[styles.btn]}> 207 + <Text style={[s.white, s.bold]}>Save Changes</Text> 208 + </LinearGradient> 209 + </TouchableOpacity> 210 + )} 176 211 <TouchableOpacity 177 - testID="editProfileSaveBtn" 178 - style={s.mt10} 179 - onPress={onPressSave}> 180 - <LinearGradient 181 - colors={[gradients.blueLight.start, gradients.blueLight.end]} 182 - start={{x: 0, y: 0}} 183 - end={{x: 1, y: 1}} 184 - style={[styles.btn]}> 185 - <Text style={[s.white, s.bold]}>Save Changes</Text> 186 - </LinearGradient> 212 + testID="editProfileCancelBtn" 213 + style={s.mt5} 214 + onPress={onPressCancel}> 215 + <View style={[styles.btn]}> 216 + <Text style={[s.black, s.bold, pal.text]}>Cancel</Text> 217 + </View> 187 218 </TouchableOpacity> 188 - )} 189 - <TouchableOpacity 190 - testID="editProfileCancelBtn" 191 - style={s.mt5} 192 - onPress={onPressCancel}> 193 - <View style={[styles.btn]}> 194 - <Text style={[s.black, s.bold, pal.text]}>Cancel</Text> 195 - </View> 196 - </TouchableOpacity> 219 + </View> 197 220 </ScrollView> 198 - </View> 221 + </KeyboardAvoidingView> 199 222 ) 200 223 } 201 224 202 225 const styles = StyleSheet.create({ 203 - inner: { 204 - padding: 14, 205 - }, 206 226 title: { 207 227 textAlign: 'center', 208 228 fontWeight: 'bold', ··· 214 234 paddingHorizontal: 4, 215 235 paddingBottom: 4, 216 236 marginTop: 20, 237 + }, 238 + form: { 239 + paddingHorizontal: 14, 217 240 }, 218 241 textInput: { 219 242 borderWidth: 1, ··· 243 266 avi: { 244 267 position: 'absolute', 245 268 top: 80, 246 - left: 10, 269 + left: 24, 247 270 width: 84, 248 271 height: 84, 249 272 borderWidth: 2,
+18 -1
src/view/com/modals/Modal.tsx
··· 1 1 import React, {useRef, useEffect} from 'react' 2 2 import {StyleSheet} from 'react-native' 3 + import {SafeAreaView} from 'react-native-safe-area-context' 3 4 import {observer} from 'mobx-react-lite' 4 5 import BottomSheet from '@gorhom/bottom-sheet' 5 6 import {useStores} from 'state/index' ··· 92 93 return null 93 94 } 94 95 96 + if (snapPoints[0] === 'fullscreen') { 97 + return ( 98 + <SafeAreaView style={[styles.fullscreenContainer, pal.view]}> 99 + {element} 100 + </SafeAreaView> 101 + ) 102 + } 103 + 95 104 return ( 96 105 <BottomSheet 97 106 ref={bottomSheetRef} 98 107 snapPoints={snapPoints} 99 108 index={store.shell.isModalActive ? 0 : -1} 100 109 enablePanDownToClose 101 - keyboardBehavior="fillParent" 110 + keyboardBehavior="extend" 111 + keyboardBlurBehavior="restore" 102 112 backdropComponent={ 103 113 store.shell.isModalActive ? createCustomBackdrop(onClose) : undefined 104 114 } ··· 114 124 handle: { 115 125 borderTopLeftRadius: 10, 116 126 borderTopRightRadius: 10, 127 + }, 128 + fullscreenContainer: { 129 + position: 'absolute', 130 + top: 0, 131 + left: 0, 132 + bottom: 0, 133 + right: 0, 117 134 }, 118 135 })
+1 -1
src/view/com/util/UserBanner.tsx
··· 128 128 width: 24, 129 129 height: 24, 130 130 bottom: 8, 131 - right: 8, 131 + right: 24, 132 132 borderRadius: 12, 133 133 alignItems: 'center', 134 134 justifyContent: 'center',
+102 -40
src/view/com/util/ViewSelector.tsx
··· 1 1 import React, {useEffect, useState} from 'react' 2 - import {View} from 'react-native' 3 - import {Selector} from './Selector' 4 - import {HorzSwipe} from './gestures/HorzSwipe' 2 + import {Pressable, StyleSheet, View} from 'react-native' 5 3 import {FlatList} from './Views' 6 - import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' 7 4 import {OnScrollCb} from 'lib/hooks/useOnMainScroll' 5 + import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' 6 + import {Text} from './text/Text' 7 + import {usePalette} from 'lib/hooks/usePalette' 8 8 import {clamp} from 'lib/numbers' 9 - import {s} from 'lib/styles' 9 + import {s, colors} from 'lib/styles' 10 + import {isAndroid} from 'platform/detection' 10 11 11 12 const HEADER_ITEM = {_reactKey: '__header__'} 12 13 const SELECTOR_ITEM = {_reactKey: '__selector__'} ··· 16 17 sections, 17 18 items, 18 19 refreshing, 19 - swipeEnabled, 20 20 renderHeader, 21 21 renderItem, 22 22 ListFooterComponent, ··· 42 42 onEndReached?: (info: {distanceFromEnd: number}) => void 43 43 }) { 44 44 const [selectedIndex, setSelectedIndex] = useState<number>(0) 45 - const panX = useAnimatedValue(0) 46 45 47 46 // events 48 47 // = 49 48 50 - const onSwipeEnd = React.useCallback( 51 - (dx: number) => { 52 - if (dx !== 0) { 53 - setSelectedIndex(clamp(selectedIndex + dx, 0, sections.length)) 54 - } 55 - }, 56 - [setSelectedIndex, selectedIndex, sections], 57 - ) 49 + const keyExtractor = React.useCallback(item => item._reactKey, []) 50 + 58 51 const onPressSelection = React.useCallback( 59 52 (index: number) => setSelectedIndex(clamp(index, 0, sections.length)), 60 53 [setSelectedIndex, sections], ··· 77 70 return ( 78 71 <Selector 79 72 items={sections} 80 - panX={panX} 81 73 selectedIndex={selectedIndex} 82 74 onSelect={onPressSelection} 83 75 /> ··· 86 78 return renderItem(item) 87 79 } 88 80 }, 89 - [sections, panX, selectedIndex, onPressSelection, renderHeader, renderItem], 81 + [sections, selectedIndex, onPressSelection, renderHeader, renderItem], 90 82 ) 91 83 92 84 const data = React.useMemo( ··· 94 86 [items], 95 87 ) 96 88 return ( 97 - <HorzSwipe 98 - hasPriority 99 - panX={panX} 100 - swipeEnabled={swipeEnabled || false} 101 - canSwipeLeft={selectedIndex > 0} 102 - canSwipeRight={selectedIndex < sections.length - 1} 103 - onSwipeEnd={onSwipeEnd}> 104 - <FlatList 105 - data={data} 106 - keyExtractor={item => item._reactKey} 107 - renderItem={renderItemInternal} 108 - ListFooterComponent={ListFooterComponent} 109 - stickyHeaderIndices={STICKY_HEADER_INDICES} 110 - refreshing={refreshing} 111 - onScroll={onScroll} 112 - onRefresh={onRefresh} 113 - onEndReached={onEndReached} 114 - onEndReachedThreshold={0.6} 115 - contentContainerStyle={s.contentContainer} 116 - removeClippedSubviews={true} 117 - scrollIndicatorInsets={{right: 1}} // fixes a bug where the scroll indicator is on the middle of the screen https://github.com/bluesky-social/social-app/pull/464 118 - /> 119 - </HorzSwipe> 89 + <FlatList 90 + data={data} 91 + keyExtractor={keyExtractor} 92 + renderItem={renderItemInternal} 93 + ListFooterComponent={ListFooterComponent} 94 + // NOTE sticky header disabled on android due to major performance issues -prf 95 + stickyHeaderIndices={isAndroid ? undefined : STICKY_HEADER_INDICES} 96 + refreshing={refreshing} 97 + onScroll={onScroll} 98 + onRefresh={onRefresh} 99 + onEndReached={onEndReached} 100 + onEndReachedThreshold={0.6} 101 + contentContainerStyle={s.contentContainer} 102 + removeClippedSubviews={true} 103 + scrollIndicatorInsets={{right: 1}} // fixes a bug where the scroll indicator is on the middle of the screen https://github.com/bluesky-social/social-app/pull/464 104 + /> 120 105 ) 121 106 } 107 + 108 + export function Selector({ 109 + selectedIndex, 110 + items, 111 + onSelect, 112 + }: { 113 + selectedIndex: number 114 + items: string[] 115 + onSelect?: (index: number) => void 116 + }) { 117 + const pal = usePalette('default') 118 + const borderColor = useColorSchemeStyle( 119 + {borderColor: colors.black}, 120 + {borderColor: colors.white}, 121 + ) 122 + 123 + const onPressItem = (index: number) => { 124 + onSelect?.(index) 125 + } 126 + 127 + return ( 128 + <View style={[pal.view, styles.outer]}> 129 + {items.map((item, i) => { 130 + const selected = i === selectedIndex 131 + return ( 132 + <Pressable 133 + testID={`selector-${i}`} 134 + key={item} 135 + onPress={() => onPressItem(i)}> 136 + <View 137 + style={[ 138 + styles.item, 139 + selected && styles.itemSelected, 140 + borderColor, 141 + ]}> 142 + <Text 143 + style={ 144 + selected 145 + ? [styles.labelSelected, pal.text] 146 + : [styles.label, pal.textLight] 147 + }> 148 + {item} 149 + </Text> 150 + </View> 151 + </Pressable> 152 + ) 153 + })} 154 + </View> 155 + ) 156 + } 157 + 158 + const styles = StyleSheet.create({ 159 + outer: { 160 + flexDirection: 'row', 161 + paddingHorizontal: 14, 162 + }, 163 + item: { 164 + marginRight: 14, 165 + paddingHorizontal: 10, 166 + paddingTop: 8, 167 + paddingBottom: 12, 168 + }, 169 + itemSelected: { 170 + borderBottomWidth: 3, 171 + }, 172 + label: { 173 + fontWeight: '600', 174 + }, 175 + labelSelected: { 176 + fontWeight: '600', 177 + }, 178 + underline: { 179 + position: 'absolute', 180 + height: 4, 181 + bottom: 0, 182 + }, 183 + })
-157
src/view/com/util/gestures/HorzSwipe.tsx
··· 1 - import React, {useState} from 'react' 2 - import { 3 - Animated, 4 - GestureResponderEvent, 5 - I18nManager, 6 - PanResponder, 7 - PanResponderGestureState, 8 - useWindowDimensions, 9 - View, 10 - } from 'react-native' 11 - import {clamp} from 'lodash' 12 - import {s} from 'lib/styles' 13 - 14 - interface Props { 15 - panX: Animated.Value 16 - canSwipeLeft?: boolean 17 - canSwipeRight?: boolean 18 - swipeEnabled?: boolean 19 - hasPriority?: boolean // if has priority, will not release control of the gesture to another gesture 20 - distThresholdDivisor?: number 21 - useNativeDriver?: boolean 22 - onSwipeStart?: () => void 23 - onSwipeStartDirection?: (dx: number) => void 24 - onSwipeEnd?: (dx: number) => void 25 - children: React.ReactNode 26 - } 27 - 28 - export function HorzSwipe({ 29 - panX, 30 - canSwipeLeft = false, 31 - canSwipeRight = false, 32 - swipeEnabled = true, 33 - hasPriority = false, 34 - distThresholdDivisor = 1.75, 35 - useNativeDriver = false, 36 - onSwipeStart, 37 - onSwipeStartDirection, 38 - onSwipeEnd, 39 - children, 40 - }: Props) { 41 - const winDim = useWindowDimensions() 42 - const [dir, setDir] = useState<number>(0) 43 - 44 - const swipeVelocityThreshold = 35 45 - const swipeDistanceThreshold = winDim.width / distThresholdDivisor 46 - 47 - const isMovingHorizontally = ( 48 - _: GestureResponderEvent, 49 - gestureState: PanResponderGestureState, 50 - ) => { 51 - return ( 52 - Math.abs(gestureState.dx) > Math.abs(gestureState.dy * 1.25) && 53 - Math.abs(gestureState.vx) > Math.abs(gestureState.vy * 1.25) 54 - ) 55 - } 56 - 57 - const canMoveScreen = ( 58 - event: GestureResponderEvent, 59 - gestureState: PanResponderGestureState, 60 - ) => { 61 - if (swipeEnabled === false) { 62 - return false 63 - } 64 - 65 - const diffX = I18nManager.isRTL ? -gestureState.dx : gestureState.dx 66 - const willHandle = 67 - isMovingHorizontally(event, gestureState) && 68 - ((diffX > 0 && canSwipeLeft) || (diffX < 0 && canSwipeRight)) 69 - return willHandle 70 - } 71 - 72 - const startGesture = () => { 73 - setDir(0) 74 - onSwipeStart?.() 75 - 76 - panX.stopAnimation() 77 - // @ts-expect-error: _value is private, but docs use it as well 78 - panX.setOffset(panX._value) 79 - } 80 - 81 - const respondToGesture = ( 82 - _: GestureResponderEvent, 83 - gestureState: PanResponderGestureState, 84 - ) => { 85 - const diffX = I18nManager.isRTL ? -gestureState.dx : gestureState.dx 86 - 87 - if ( 88 - // swiping left 89 - (diffX > 0 && !canSwipeLeft) || 90 - // swiping right 91 - (diffX < 0 && !canSwipeRight) 92 - ) { 93 - panX.setValue(0) 94 - return 95 - } 96 - 97 - panX.setValue(clamp(diffX / swipeDistanceThreshold, -1, 1) * -1) 98 - 99 - const newDir = diffX > 0 ? -1 : diffX < 0 ? 1 : 0 100 - if (newDir !== dir) { 101 - setDir(newDir) 102 - onSwipeStartDirection?.(newDir) 103 - } 104 - } 105 - 106 - const finishGesture = ( 107 - _: GestureResponderEvent, 108 - gestureState: PanResponderGestureState, 109 - ) => { 110 - if ( 111 - Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && 112 - Math.abs(gestureState.vx) > Math.abs(gestureState.vy) && 113 - (Math.abs(gestureState.dx) > swipeDistanceThreshold / 4 || 114 - Math.abs(gestureState.vx) > swipeVelocityThreshold) 115 - ) { 116 - const final = Math.floor( 117 - (gestureState.dx / Math.abs(gestureState.dx)) * -1, 118 - ) 119 - Animated.timing(panX, { 120 - toValue: final, 121 - duration: 100, 122 - useNativeDriver, 123 - isInteraction: false, 124 - }).start(() => { 125 - onSwipeEnd?.(final) 126 - panX.flattenOffset() 127 - panX.setValue(0) 128 - }) 129 - } else { 130 - onSwipeEnd?.(0) 131 - Animated.timing(panX, { 132 - toValue: 0, 133 - duration: 100, 134 - useNativeDriver, 135 - isInteraction: false, 136 - }).start(() => { 137 - panX.flattenOffset() 138 - panX.setValue(0) 139 - }) 140 - } 141 - } 142 - 143 - const panResponder = PanResponder.create({ 144 - onMoveShouldSetPanResponder: canMoveScreen, 145 - onPanResponderGrant: startGesture, 146 - onPanResponderMove: respondToGesture, 147 - onPanResponderTerminate: finishGesture, 148 - onPanResponderRelease: finishGesture, 149 - onPanResponderTerminationRequest: () => !hasPriority, 150 - }) 151 - 152 - return ( 153 - <View {...panResponder.panHandlers} style={s.h100pct}> 154 - {children} 155 - </View> 156 - ) 157 - }
-302
src/view/com/util/gestures/SwipeAndZoom.tsx
··· 1 - import React, {useState} from 'react' 2 - import { 3 - Animated, 4 - GestureResponderEvent, 5 - I18nManager, 6 - PanResponder, 7 - PanResponderGestureState, 8 - useWindowDimensions, 9 - View, 10 - } from 'react-native' 11 - import {clamp} from 'lodash' 12 - import {s} from 'lib/styles' 13 - 14 - export enum Dir { 15 - None, 16 - Up, 17 - Down, 18 - Left, 19 - Right, 20 - Zoom, 21 - } 22 - 23 - interface Props { 24 - panX: Animated.Value 25 - panY: Animated.Value 26 - zoom: Animated.Value 27 - canSwipeLeft?: boolean 28 - canSwipeRight?: boolean 29 - canSwipeUp?: boolean 30 - canSwipeDown?: boolean 31 - swipeEnabled?: boolean 32 - zoomEnabled?: boolean 33 - hasPriority?: boolean // if has priority, will not release control of the gesture to another gesture 34 - horzDistThresholdDivisor?: number 35 - vertDistThresholdDivisor?: number 36 - useNativeDriver?: boolean 37 - onSwipeStart?: () => void 38 - onSwipeStartDirection?: (dir: Dir) => void 39 - onSwipeEnd?: (dir: Dir) => void 40 - children: React.ReactNode 41 - } 42 - 43 - export function SwipeAndZoom({ 44 - panX, 45 - panY, 46 - zoom, 47 - canSwipeLeft = false, 48 - canSwipeRight = false, 49 - canSwipeUp = false, 50 - canSwipeDown = false, 51 - swipeEnabled = false, 52 - zoomEnabled = false, 53 - hasPriority = false, 54 - horzDistThresholdDivisor = 1.75, 55 - vertDistThresholdDivisor = 1.75, 56 - useNativeDriver = false, 57 - onSwipeStart, 58 - onSwipeStartDirection, 59 - onSwipeEnd, 60 - children, 61 - }: Props) { 62 - const winDim = useWindowDimensions() 63 - const [dir, setDir] = useState<Dir>(Dir.None) 64 - const [initialDistance, setInitialDistance] = useState<number | undefined>( 65 - undefined, 66 - ) 67 - 68 - const swipeVelocityThreshold = 35 69 - const swipeHorzDistanceThreshold = winDim.width / horzDistThresholdDivisor 70 - const swipeVertDistanceThreshold = winDim.height / vertDistThresholdDivisor 71 - 72 - const isMovingHorizontally = ( 73 - _: GestureResponderEvent, 74 - gestureState: PanResponderGestureState, 75 - ) => { 76 - return ( 77 - Math.abs(gestureState.dx) > Math.abs(gestureState.dy * 1.25) && 78 - Math.abs(gestureState.vx) > Math.abs(gestureState.vy * 1.25) 79 - ) 80 - } 81 - const isMovingVertically = ( 82 - _: GestureResponderEvent, 83 - gestureState: PanResponderGestureState, 84 - ) => { 85 - return ( 86 - Math.abs(gestureState.dy) > Math.abs(gestureState.dx * 1.25) && 87 - Math.abs(gestureState.vy) > Math.abs(gestureState.vx * 1.25) 88 - ) 89 - } 90 - 91 - const canDir = (d: Dir) => { 92 - if (d === Dir.Left) { 93 - return canSwipeLeft 94 - } 95 - if (d === Dir.Right) { 96 - return canSwipeRight 97 - } 98 - if (d === Dir.Up) { 99 - return canSwipeUp 100 - } 101 - if (d === Dir.Down) { 102 - return canSwipeDown 103 - } 104 - if (d === Dir.Zoom) { 105 - return zoomEnabled 106 - } 107 - return false 108 - } 109 - const isHorz = (d: Dir) => d === Dir.Left || d === Dir.Right 110 - const isVert = (d: Dir) => d === Dir.Up || d === Dir.Down 111 - 112 - const canMoveScreen = ( 113 - event: GestureResponderEvent, 114 - gestureState: PanResponderGestureState, 115 - ) => { 116 - if (zoomEnabled && gestureState.numberActiveTouches === 2) { 117 - return true 118 - } else if (swipeEnabled && gestureState.numberActiveTouches === 1) { 119 - const dx = I18nManager.isRTL ? -gestureState.dx : gestureState.dx 120 - const dy = gestureState.dy 121 - const willHandle = 122 - (isMovingHorizontally(event, gestureState) && 123 - ((dx > 0 && canSwipeLeft) || (dx < 0 && canSwipeRight))) || 124 - (isMovingVertically(event, gestureState) && 125 - ((dy > 0 && canSwipeUp) || (dy < 0 && canSwipeDown))) 126 - return willHandle 127 - } 128 - return false 129 - } 130 - 131 - const startGesture = () => { 132 - setDir(Dir.None) 133 - onSwipeStart?.() 134 - 135 - // reset all state 136 - panX.stopAnimation() 137 - // @ts-expect-error: _value is private, but docs use it as well 138 - panX.setOffset(panX._value) 139 - panY.stopAnimation() 140 - // @ts-expect-error: _value is private, but docs use it as well 141 - panY.setOffset(panY._value) 142 - zoom.stopAnimation() 143 - // @ts-expect-error: _value is private, but docs use it as well 144 - zoom.setOffset(zoom._value) 145 - setInitialDistance(undefined) 146 - } 147 - 148 - const respondToGesture = ( 149 - e: GestureResponderEvent, 150 - gestureState: PanResponderGestureState, 151 - ) => { 152 - const dx = I18nManager.isRTL ? -gestureState.dx : gestureState.dx 153 - const dy = gestureState.dy 154 - 155 - let newDir = Dir.None 156 - if (dir === Dir.None) { 157 - // establish if the user is swiping horz or vert, or zooming 158 - if (gestureState.numberActiveTouches === 2) { 159 - newDir = Dir.Zoom 160 - } else if (Math.abs(dx) > Math.abs(dy)) { 161 - newDir = dx > 0 ? Dir.Left : Dir.Right 162 - } else { 163 - newDir = dy > 0 ? Dir.Up : Dir.Down 164 - } 165 - } else if (isHorz(dir)) { 166 - // direction update 167 - newDir = dx > 0 ? Dir.Left : Dir.Right 168 - } else if (isVert(dir)) { 169 - // direction update 170 - newDir = dy > 0 ? Dir.Up : Dir.Down 171 - } else { 172 - newDir = dir 173 - } 174 - 175 - if (newDir === Dir.Zoom) { 176 - if (zoomEnabled) { 177 - if (gestureState.numberActiveTouches === 2) { 178 - // zoom in/out 179 - const x0 = e.nativeEvent.touches[0].pageX 180 - const x1 = e.nativeEvent.touches[1].pageX 181 - const y0 = e.nativeEvent.touches[0].pageY 182 - const y1 = e.nativeEvent.touches[1].pageY 183 - const zoomDx = Math.abs(x0 - x1) 184 - const zoomDy = Math.abs(y0 - y1) 185 - const dist = Math.sqrt(zoomDx * zoomDx + zoomDy * zoomDy) / 100 186 - if ( 187 - typeof initialDistance === 'undefined' || 188 - dist - initialDistance < 0 189 - ) { 190 - setInitialDistance(dist) 191 - } else { 192 - zoom.setValue(dist - initialDistance) 193 - } 194 - } else { 195 - // pan around after zooming 196 - panX.setValue(clamp(dx / winDim.width, -1, 1) * -1) 197 - panY.setValue(clamp(dy / winDim.height, -1, 1) * -1) 198 - } 199 - } 200 - } else if (isHorz(newDir)) { 201 - // swipe left/right 202 - panX.setValue( 203 - clamp( 204 - dx / swipeHorzDistanceThreshold, 205 - canSwipeRight ? -1 : 0, 206 - canSwipeLeft ? 1 : 0, 207 - ) * -1, 208 - ) 209 - panY.setValue(0) 210 - } else if (isVert(newDir)) { 211 - // swipe up/down 212 - panY.setValue( 213 - clamp( 214 - dy / swipeVertDistanceThreshold, 215 - canSwipeDown ? -1 : 0, 216 - canSwipeUp ? 1 : 0, 217 - ) * -1, 218 - ) 219 - panX.setValue(0) 220 - } 221 - 222 - if (!canDir(newDir)) { 223 - newDir = Dir.None 224 - } 225 - if (newDir !== dir) { 226 - setDir(newDir) 227 - onSwipeStartDirection?.(newDir) 228 - } 229 - } 230 - 231 - const finishGesture = ( 232 - _: GestureResponderEvent, 233 - gestureState: PanResponderGestureState, 234 - ) => { 235 - const finish = (finalDir: Dir) => () => { 236 - if (finalDir !== Dir.None) { 237 - onSwipeEnd?.(finalDir) 238 - } 239 - setDir(Dir.None) 240 - panX.flattenOffset() 241 - panX.setValue(0) 242 - panY.flattenOffset() 243 - panY.setValue(0) 244 - } 245 - if ( 246 - isHorz(dir) && 247 - (Math.abs(gestureState.dx) > swipeHorzDistanceThreshold / 4 || 248 - Math.abs(gestureState.vx) > swipeVelocityThreshold) 249 - ) { 250 - // horizontal swipe reset 251 - Animated.timing(panX, { 252 - toValue: dir === Dir.Left ? -1 : 1, 253 - duration: 100, 254 - useNativeDriver, 255 - }).start(finish(dir)) 256 - } else if ( 257 - isVert(dir) && 258 - (Math.abs(gestureState.dy) > swipeVertDistanceThreshold / 8 || 259 - Math.abs(gestureState.vy) > swipeVelocityThreshold) 260 - ) { 261 - // vertical swipe reset 262 - Animated.timing(panY, { 263 - toValue: dir === Dir.Up ? -1 : 1, 264 - duration: 100, 265 - useNativeDriver, 266 - }).start(finish(dir)) 267 - } else { 268 - // zoom (or no direction) reset 269 - onSwipeEnd?.(Dir.None) 270 - Animated.timing(panX, { 271 - toValue: 0, 272 - duration: 100, 273 - useNativeDriver, 274 - }).start() 275 - Animated.timing(panY, { 276 - toValue: 0, 277 - duration: 100, 278 - useNativeDriver, 279 - }).start() 280 - Animated.timing(zoom, { 281 - toValue: 0, 282 - duration: 100, 283 - useNativeDriver, 284 - }).start() 285 - } 286 - } 287 - 288 - const panResponder = PanResponder.create({ 289 - onMoveShouldSetPanResponder: canMoveScreen, 290 - onPanResponderGrant: startGesture, 291 - onPanResponderMove: respondToGesture, 292 - onPanResponderTerminate: finishGesture, 293 - onPanResponderRelease: finishGesture, 294 - onPanResponderTerminationRequest: () => !hasPriority, 295 - }) 296 - 297 - return ( 298 - <View {...panResponder.panHandlers} style={s.h100pct}> 299 - {children} 300 - </View> 301 - ) 302 - }
+6 -6
src/view/screens/Profile.tsx
··· 18 18 import {Text} from '../com/util/text/Text' 19 19 import {FAB} from '../com/util/fab/FAB' 20 20 import {s, colors} from 'lib/styles' 21 - import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' 22 21 import {useAnalytics} from 'lib/analytics' 23 22 import {ComposeIcon2} from 'lib/icons' 24 23 ··· 32 31 screen('Profile') 33 32 }, [screen]) 34 33 35 - const onMainScroll = useOnMainScroll(store) 36 34 const [hasSetup, setHasSetup] = useState<boolean>(false) 37 35 const uiState = React.useMemo( 38 36 () => new ProfileUiModel(store, {user: route.params.name}), ··· 68 66 track('ProfileScreen:PressCompose') 69 67 store.shell.openComposer({}) 70 68 }, [store, track]) 71 - const onSelectView = (index: number) => { 72 - uiState.setSelectedViewIndex(index) 73 - } 69 + const onSelectView = React.useCallback( 70 + (index: number) => { 71 + uiState.setSelectedViewIndex(index) 72 + }, 73 + [uiState], 74 + ) 74 75 const onRefresh = React.useCallback(() => { 75 76 uiState 76 77 .refresh() ··· 158 159 ListFooterComponent={Footer} 159 160 refreshing={uiState.isRefreshing || false} 160 161 onSelectView={onSelectView} 161 - onScroll={onMainScroll} 162 162 onRefresh={onRefresh} 163 163 onEndReached={onEndReached} 164 164 />