Bluesky app fork with some witchin' additions 💫

Improve the profile preview with "swipe up to view" and local cache optimization (#1096)

* Update the ProfilePreview to use a swipe-up to navigate

* Use the profile cache to optimize load performance

* Hack to align the header in the profile preview against the screen view

* Fix profiles cache logic to ensure cache is used

* Fix dark mode on profile preview

authored by

Paul Frazee and committed by
GitHub
96280d5f 1211c353

+98 -61
+2
package.json
··· 96 96 "lodash.debounce": "^4.0.8", 97 97 "lodash.isequal": "^4.5.0", 98 98 "lodash.omit": "^4.5.0", 99 + "lodash.once": "^4.1.1", 99 100 "lodash.samplesize": "^4.2.0", 100 101 "lodash.set": "^4.3.2", 101 102 "lodash.shuffle": "^4.2.0", ··· 161 162 "@types/lodash.debounce": "^4.0.7", 162 163 "@types/lodash.isequal": "^4.5.6", 163 164 "@types/lodash.omit": "^4.5.7", 165 + "@types/lodash.once": "^4.1.7", 164 166 "@types/lodash.samplesize": "^4.2.7", 165 167 "@types/lodash.set": "^4.3.7", 166 168 "@types/lodash.shuffle": "^4.2.7",
+4 -1
src/Navigation.tsx
··· 125 125 <Stack.Screen 126 126 name="Profile" 127 127 component={ProfileScreen} 128 - options={({route}) => ({title: title(`@${route.params.name}`)})} 128 + options={({route}) => ({ 129 + title: title(`@${route.params.name}`), 130 + animation: 'none', 131 + })} 129 132 /> 130 133 <Stack.Screen 131 134 name="ProfileFollowers"
+1 -3
src/state/models/cache/profiles-view.ts
··· 45 45 } 46 46 47 47 overwrite(did: string, res: GetProfile.Response) { 48 - if (this.cache.has(did)) { 49 - this.cache.set(did, res) 50 - } 48 + this.cache.set(did, res) 51 49 } 52 50 }
+24 -2
src/state/models/content/profile.ts
··· 103 103 // = 104 104 105 105 async setup() { 106 - await this._load() 106 + const precache = await this.rootStore.profiles.cache.get(this.params.actor) 107 + if (precache) { 108 + await this._loadWithCache(precache) 109 + } else { 110 + await this._load() 111 + } 107 112 } 108 113 109 114 async refresh() { ··· 252 257 this._xLoading(isRefreshing) 253 258 try { 254 259 const res = await this.rootStore.agent.getProfile(this.params) 255 - this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation 260 + this.rootStore.profiles.overwrite(this.params.actor, res) 256 261 if (res.data.handle) { 257 262 this.rootStore.handleResolutions.cache.set( 258 263 res.data.handle, ··· 262 267 this._replaceAll(res) 263 268 await this._createRichText() 264 269 this._xIdle() 270 + } catch (e: any) { 271 + this._xIdle(e) 272 + } 273 + } 274 + 275 + async _loadWithCache(precache: GetProfile.Response) { 276 + // use cached value 277 + this._replaceAll(precache) 278 + await this._createRichText() 279 + this._xIdle() 280 + 281 + // fetch latest 282 + try { 283 + const res = await this.rootStore.agent.getProfile(this.params) 284 + this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation 285 + this._replaceAll(res) 286 + await this._createRichText() 265 287 } catch (e: any) { 266 288 this._xIdle(e) 267 289 }
+19 -3
src/view/com/modals/Modal.tsx
··· 6 6 import {useStores} from 'state/index' 7 7 import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' 8 8 import {usePalette} from 'lib/hooks/usePalette' 9 + import {navigate} from '../../../Navigation' 10 + import once from 'lodash.once' 9 11 10 12 import * as ConfirmModal from './Confirm' 11 13 import * as EditProfileModal from './EditProfile' ··· 35 37 const store = useStores() 36 38 const bottomSheetRef = useRef<BottomSheet>(null) 37 39 const pal = usePalette('default') 40 + 41 + const activeModal = 42 + store.shell.activeModals[store.shell.activeModals.length - 1] 43 + 44 + const navigateOnce = once(navigate) 45 + 46 + const onBottomSheetAnimate = (fromIndex: number, toIndex: number) => { 47 + if (activeModal?.name === 'profile-preview' && toIndex === 1) { 48 + // begin loading the profile screen behind the scenes 49 + navigateOnce('Profile', {name: activeModal.did}) 50 + } 51 + } 38 52 const onBottomSheetChange = (snapPoint: number) => { 39 53 if (snapPoint === -1) { 40 54 store.shell.closeModal() 55 + } else if (activeModal?.name === 'profile-preview' && snapPoint === 1) { 56 + // ensure we navigate to Profile and close the modal 57 + navigateOnce('Profile', {name: activeModal.did}) 58 + store.shell.closeModal() 41 59 } 42 60 } 43 61 const onClose = () => { 44 62 bottomSheetRef.current?.close() 45 63 store.shell.closeModal() 46 64 } 47 - 48 - const activeModal = 49 - store.shell.activeModals[store.shell.activeModals.length - 1] 50 65 51 66 useEffect(() => { 52 67 if (store.shell.isModalActive) { ··· 146 161 } 147 162 handleIndicatorStyle={{backgroundColor: pal.text.color}} 148 163 handleStyle={[styles.handle, pal.view]} 164 + onAnimate={onBottomSheetAnimate} 149 165 onChange={onBottomSheetChange}> 150 166 {element} 151 167 </BottomSheet>
+40 -51
src/view/com/modals/ProfilePreview.tsx
··· 1 - import React, {useState, useEffect, useCallback} from 'react' 2 - import {StyleSheet, View} from 'react-native' 1 + import React, {useState, useEffect} from 'react' 2 + import {ActivityIndicator, StyleSheet, View} from 'react-native' 3 3 import {observer} from 'mobx-react-lite' 4 - import {useNavigation, StackActions} from '@react-navigation/native' 5 - import {Text} from '../util/text/Text' 4 + import {ThemedText} from '../util/text/ThemedText' 6 5 import {useStores} from 'state/index' 7 6 import {ProfileModel} from 'state/models/content/profile' 8 7 import {usePalette} from 'lib/hooks/usePalette' 9 8 import {useAnalytics} from 'lib/analytics/analytics' 10 9 import {ProfileHeader} from '../profile/ProfileHeader' 11 - import {Button} from '../util/forms/Button' 12 - import {NavigationProp} from 'lib/routes/types' 10 + import {InfoCircleIcon} from 'lib/icons' 11 + import {useNavigationState} from '@react-navigation/native' 12 + import {isIOS} from 'platform/detection' 13 + import {s} from 'lib/styles' 13 14 14 - export const snapPoints = [560] 15 + export const snapPoints = [520, '100%'] 15 16 16 17 export const Component = observer(({did}: {did: string}) => { 17 18 const store = useStores() 18 19 const pal = usePalette('default') 19 - const palInverted = usePalette('inverted') 20 - const navigation = useNavigation<NavigationProp>() 21 20 const [model] = useState(new ProfileModel(store, {actor: did})) 22 21 const {screen} = useAnalytics() 23 22 23 + // track the navigator state to detect if a page-load occurred 24 + const navState = useNavigationState(s => s) 25 + const [initNavState] = useState(navState) 26 + const isLoading = initNavState !== navState 27 + 24 28 useEffect(() => { 25 29 screen('Profile:Preview') 26 30 model.setup() 27 31 }, [model, screen]) 28 32 29 - const onPressViewProfile = useCallback(() => { 30 - navigation.dispatch(StackActions.push('Profile', {name: model.handle})) 31 - store.shell.closeModal() 32 - }, [navigation, store, model]) 33 - 34 33 return ( 35 - <View style={pal.view}> 36 - <View style={styles.headerWrapper}> 34 + <View style={[pal.view, s.flex1]}> 35 + <View 36 + style={[ 37 + styles.headerWrapper, 38 + isLoading && isIOS && styles.headerPositionAdjust, 39 + ]}> 37 40 <ProfileHeader view={model} hideBackButton onRefreshAll={() => {}} /> 38 41 </View> 39 - <View style={[styles.buttonsContainer, pal.view]}> 40 - <View style={styles.buttons}> 41 - <Button 42 - type="inverted" 43 - style={[styles.button, styles.buttonWide]} 44 - onPress={onPressViewProfile} 45 - accessibilityLabel="View profile" 46 - accessibilityHint=""> 47 - <Text type="button-lg" style={palInverted.text}> 48 - View Profile 49 - </Text> 50 - </Button> 51 - <Button 52 - type="default" 53 - style={styles.button} 54 - onPress={() => store.shell.closeModal()} 55 - accessibilityLabel="Close this preview" 56 - accessibilityHint=""> 57 - <Text type="button-lg" style={pal.text}> 58 - Close 59 - </Text> 60 - </Button> 42 + <View style={[styles.hintWrapper, pal.view]}> 43 + <View style={styles.hint}> 44 + {isLoading ? ( 45 + <ActivityIndicator /> 46 + ) : ( 47 + <> 48 + <InfoCircleIcon size={21} style={pal.textLight} /> 49 + <ThemedText type="xl" fg="light"> 50 + Swipe up to see more 51 + </ThemedText> 52 + </> 53 + )} 61 54 </View> 62 55 </View> 63 56 </View> ··· 68 61 headerWrapper: { 69 62 height: 440, 70 63 }, 71 - buttonsContainer: { 72 - height: 120, 64 + headerPositionAdjust: { 65 + // HACK align the header for the profilescreen transition -prf 66 + paddingTop: 23, 73 67 }, 74 - buttons: { 75 - flexDirection: 'row', 76 - gap: 8, 77 - paddingHorizontal: 14, 78 - paddingTop: 16, 68 + hintWrapper: { 69 + height: 80, 79 70 }, 80 - button: { 81 - flex: 2, 71 + hint: { 82 72 flexDirection: 'row', 83 73 justifyContent: 'center', 84 - paddingVertical: 12, 85 - }, 86 - buttonWide: { 87 - flex: 3, 74 + gap: 8, 75 + paddingHorizontal: 14, 76 + borderRadius: 6, 88 77 }, 89 78 })
+8 -1
yarn.lock
··· 6343 6343 dependencies: 6344 6344 "@types/lodash" "*" 6345 6345 6346 + "@types/lodash.once@^4.1.7": 6347 + version "4.1.7" 6348 + resolved "https://registry.yarnpkg.com/@types/lodash.once/-/lodash.once-4.1.7.tgz#84bc1f711725f6cd6d8be04365623141e09bc007" 6349 + integrity sha512-XWhnXzWkxoleOoXKmzUtep8vT+wiiQQgmPD+wzG0yO0bdlszmnqHRb2WiY5hK/8V0DTet1+z9DJj9cnbdAhWng== 6350 + dependencies: 6351 + "@types/lodash" "*" 6352 + 6346 6353 "@types/lodash.samplesize@^4.2.7": 6347 6354 version "4.2.7" 6348 6355 resolved "https://registry.yarnpkg.com/@types/lodash.samplesize/-/lodash.samplesize-4.2.7.tgz#15784dd9e54aa1bf043552bdb533b83fcf50b82f" ··· 13912 13919 resolved "https://registry.yarnpkg.com/lodash.omit/-/lodash.omit-4.5.0.tgz#6eb19ae5a1ee1dd9df0b969e66ce0b7fa30b5e60" 13913 13920 integrity sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg== 13914 13921 13915 - lodash.once@^4.0.0: 13922 + lodash.once@^4.0.0, lodash.once@^4.1.1: 13916 13923 version "4.1.1" 13917 13924 resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" 13918 13925 integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==