Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Fix flashes and jumps when opening profile (#2815)

* Don't reset the tree when profile loads fully

* Give avatars a background color like placeholders

* Prevent jumps due to rich text resolving

* Rm log

* Rm unused

authored by

dan and committed by
GitHub
d36b91fe 0d00c7d8

+141 -135
+13 -11
src/state/cache/profile-shadow.ts
··· 22 22 blockingUri: string | undefined 23 23 } 24 24 25 - type ProfileView = 26 - | AppBskyActorDefs.ProfileView 27 - | AppBskyActorDefs.ProfileViewBasic 28 - | AppBskyActorDefs.ProfileViewDetailed 29 - 30 - const shadows: WeakMap<ProfileView, Partial<ProfileShadow>> = new WeakMap() 25 + const shadows: WeakMap< 26 + AppBskyActorDefs.ProfileView, 27 + Partial<ProfileShadow> 28 + > = new WeakMap() 31 29 const emitter = new EventEmitter() 32 30 33 - export function useProfileShadow(profile: ProfileView): Shadow<ProfileView> { 31 + export function useProfileShadow< 32 + TProfileView extends AppBskyActorDefs.ProfileView, 33 + >(profile: TProfileView): Shadow<TProfileView> { 34 34 const [shadow, setShadow] = useState(() => shadows.get(profile)) 35 35 const [prevPost, setPrevPost] = useState(profile) 36 36 if (profile !== prevPost) { ··· 70 70 }) 71 71 } 72 72 73 - function mergeShadow( 74 - profile: ProfileView, 73 + function mergeShadow<TProfileView extends AppBskyActorDefs.ProfileView>( 74 + profile: TProfileView, 75 75 shadow: Partial<ProfileShadow>, 76 - ): Shadow<ProfileView> { 76 + ): Shadow<TProfileView> { 77 77 return castAsShadow({ 78 78 ...profile, 79 79 viewer: { ··· 89 89 }) 90 90 } 91 91 92 - function* findProfilesInCache(did: string): Generator<ProfileView, void> { 92 + function* findProfilesInCache( 93 + did: string, 94 + ): Generator<AppBskyActorDefs.ProfileView, void> { 93 95 yield* findAllProfilesInListMembersQueryData(queryClient, did) 94 96 yield* findAllProfilesInMyBlockedAccountsQueryData(queryClient, did) 95 97 yield* findAllProfilesInMyMutedAccountsQueryData(queryClient, did)
+9 -13
src/view/com/pager/PagerWithHeader.tsx
··· 61 61 const headerHeight = headerOnlyHeight + tabBarHeight 62 62 63 63 // capture the header bar sizing 64 - const onTabBarLayout = React.useCallback( 65 - (evt: LayoutChangeEvent) => { 66 - const height = evt.nativeEvent.layout.height 67 - if (height > 0) { 68 - // The rounding is necessary to prevent jumps on iOS 69 - setTabBarHeight(Math.round(height)) 70 - } 71 - }, 72 - [setTabBarHeight], 73 - ) 74 - const onHeaderOnlyLayout = React.useCallback( 64 + const onTabBarLayout = useNonReactiveCallback((evt: LayoutChangeEvent) => { 65 + const height = evt.nativeEvent.layout.height 66 + if (height > 0) { 67 + // The rounding is necessary to prevent jumps on iOS 68 + setTabBarHeight(Math.round(height)) 69 + } 70 + }) 71 + const onHeaderOnlyLayout = useNonReactiveCallback( 75 72 (evt: LayoutChangeEvent) => { 76 73 const height = evt.nativeEvent.layout.height 77 - if (height > 0) { 74 + if (height > 0 && isHeaderReady) { 78 75 // The rounding is necessary to prevent jumps on iOS 79 76 setHeaderOnlyHeight(Math.round(height)) 80 77 } 81 78 }, 82 - [setHeaderOnlyHeight], 83 79 ) 84 80 85 81 const renderTabBar = React.useCallback(
+31 -3
src/view/com/pager/PagerWithHeader.web.tsx
··· 31 31 children, 32 32 testID, 33 33 items, 34 + isHeaderReady, 34 35 renderHeader, 35 36 initialPage, 36 37 onPageSelected, ··· 46 47 <PagerTabBar 47 48 items={items} 48 49 renderHeader={renderHeader} 50 + isHeaderReady={isHeaderReady} 49 51 currentPage={currentPage} 50 52 onCurrentPageSelected={onCurrentPageSelected} 51 53 onSelect={props.onSelect} ··· 54 56 /> 55 57 ) 56 58 }, 57 - [items, renderHeader, currentPage, onCurrentPageSelected, testID], 59 + [ 60 + items, 61 + isHeaderReady, 62 + renderHeader, 63 + currentPage, 64 + onCurrentPageSelected, 65 + testID, 66 + ], 58 67 ) 59 68 60 69 const onPageSelectedInner = React.useCallback( ··· 80 89 {toArray(children) 81 90 .filter(Boolean) 82 91 .map((child, i) => { 92 + const isReady = isHeaderReady 83 93 return ( 84 - <View key={i} collapsable={false}> 94 + <View 95 + key={i} 96 + collapsable={false} 97 + style={{ 98 + display: isReady ? undefined : 'none', 99 + }}> 85 100 <PagerItem isFocused={i === currentPage} renderTab={child} /> 86 101 </View> 87 102 ) ··· 94 109 let PagerTabBar = ({ 95 110 currentPage, 96 111 items, 112 + isHeaderReady, 97 113 testID, 98 114 renderHeader, 99 115 onCurrentPageSelected, ··· 104 120 items: string[] 105 121 testID?: string 106 122 renderHeader?: () => JSX.Element 123 + isHeaderReady: boolean 107 124 onCurrentPageSelected?: (index: number) => void 108 125 onSelect?: (index: number) => void 109 126 tabBarAnchor?: JSX.Element | null | undefined ··· 112 129 const {isMobile} = useWebMediaQueries() 113 130 return ( 114 131 <> 115 - <View style={[!isMobile && styles.headerContainerDesktop, pal.border]}> 132 + <View 133 + style={[ 134 + !isMobile && styles.headerContainerDesktop, 135 + pal.border, 136 + !isHeaderReady && styles.loadingHeader, 137 + ]}> 116 138 {renderHeader?.()} 117 139 </View> 118 140 {tabBarAnchor} ··· 123 145 ? styles.tabBarContainerMobile 124 146 : styles.tabBarContainerDesktop, 125 147 pal.border, 148 + { 149 + display: isHeaderReady ? undefined : 'none', 150 + }, 126 151 ]}> 127 152 <TabBar 128 153 testID={testID} ··· 182 207 tabBarContainerMobile: { 183 208 paddingLeft: 14, 184 209 paddingRight: 14, 210 + }, 211 + loadingHeader: { 212 + borderColor: 'transparent', 185 213 }, 186 214 }) 187 215
+27 -96
src/view/com/profile/ProfileHeader.tsx
··· 51 51 import {shareUrl} from 'lib/sharing' 52 52 import {s, colors} from 'lib/styles' 53 53 import {logger} from '#/logger' 54 - import {useSession, getAgent} from '#/state/session' 54 + import {useSession} from '#/state/session' 55 55 import {Shadow} from '#/state/cache/types' 56 56 import {useRequireAuth} from '#/state/session' 57 57 import {LabelInfo} from '../util/moderation/LabelInfo' 58 58 import {useProfileShadow} from 'state/cache/profile-shadow' 59 59 60 - interface Props { 61 - profile: AppBskyActorDefs.ProfileView | null 62 - placeholderData?: AppBskyActorDefs.ProfileView | null 63 - moderationOpts: ModerationOpts | null 64 - hideBackButton?: boolean 65 - isProfilePreview?: boolean 66 - } 67 - 68 - export function ProfileHeader({ 69 - profile, 70 - moderationOpts, 71 - hideBackButton = false, 72 - isProfilePreview, 73 - }: Props) { 60 + let ProfileHeaderLoading = (_props: {}): React.ReactNode => { 74 61 const pal = usePalette('default') 75 - 76 - // loading 77 - // = 78 - if (!profile || !moderationOpts) { 79 - return ( 80 - <View style={pal.view}> 81 - <LoadingPlaceholder 82 - width="100%" 83 - height={150} 84 - style={{borderRadius: 0}} 85 - /> 86 - <View 87 - style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> 88 - <LoadingPlaceholder width={80} height={80} style={styles.br40} /> 89 - </View> 90 - <View style={styles.content}> 91 - <View style={[styles.buttonsLine]}> 92 - <LoadingPlaceholder width={167} height={31} style={styles.br50} /> 93 - </View> 62 + return ( 63 + <View style={pal.view}> 64 + <LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} /> 65 + <View 66 + style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> 67 + <LoadingPlaceholder width={80} height={80} style={styles.br40} /> 68 + </View> 69 + <View style={styles.content}> 70 + <View style={[styles.buttonsLine]}> 71 + <LoadingPlaceholder width={167} height={31} style={styles.br50} /> 94 72 </View> 95 73 </View> 96 - ) 97 - } 98 - 99 - // loaded 100 - // = 101 - return ( 102 - <ProfileHeaderLoaded 103 - profile={profile} 104 - moderationOpts={moderationOpts} 105 - hideBackButton={hideBackButton} 106 - isProfilePreview={isProfilePreview} 107 - /> 74 + </View> 108 75 ) 109 76 } 77 + ProfileHeaderLoading = memo(ProfileHeaderLoading) 78 + export {ProfileHeaderLoading} 110 79 111 - interface LoadedProps { 80 + interface Props { 112 81 profile: AppBskyActorDefs.ProfileViewDetailed 82 + descriptionRT: RichTextAPI | null 113 83 moderationOpts: ModerationOpts 114 84 hideBackButton?: boolean 115 - isProfilePreview?: boolean 85 + isPlaceholderProfile?: boolean 116 86 } 117 87 118 - let ProfileHeaderLoaded = ({ 88 + let ProfileHeader = ({ 119 89 profile: profileUnshadowed, 90 + descriptionRT, 120 91 moderationOpts, 121 92 hideBackButton = false, 122 - isProfilePreview, 123 - }: LoadedProps): React.ReactNode => { 93 + isPlaceholderProfile, 94 + }: Props): React.ReactNode => { 124 95 const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = 125 96 useProfileShadow(profileUnshadowed) 126 97 const pal = usePalette('default') ··· 144 115 [profile, moderationOpts], 145 116 ) 146 117 147 - /* 148 - * BEGIN handle bio facet resolution 149 - */ 150 - // should be undefined on first render to trigger a resolution 151 - const prevProfileDescription = React.useRef<string | undefined>() 152 - const [descriptionRT, setDescriptionRT] = React.useState< 153 - RichTextAPI | undefined 154 - >( 155 - profile.description 156 - ? new RichTextAPI({text: profile.description}) 157 - : undefined, 158 - ) 159 - React.useEffect(() => { 160 - async function resolveRTFacets() { 161 - // new each time 162 - const rt = new RichTextAPI({text: profile.description || ''}) 163 - await rt.detectFacets(getAgent()) 164 - // replace existing RT instance 165 - setDescriptionRT(rt) 166 - } 167 - 168 - if (profile.description !== prevProfileDescription.current) { 169 - // update prev immediately 170 - prevProfileDescription.current = profile.description 171 - resolveRTFacets() 172 - } 173 - }, [profile.description, setDescriptionRT]) 174 - /* 175 - * END handle bio facet resolution 176 - */ 177 - 178 118 const invalidateProfileQuery = React.useCallback(() => { 179 119 queryClient.invalidateQueries({ 180 120 queryKey: profileQueryKey(profile.did), ··· 454 394 const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') 455 395 456 396 return ( 457 - <View 458 - style={[ 459 - pal.view, 460 - isProfilePreview && isDesktop && styles.loadingBorderStyle, 461 - ]} 462 - pointerEvents="box-none"> 397 + <View style={[pal.view]} pointerEvents="box-none"> 463 398 <View pointerEvents="none"> 464 - {isProfilePreview ? ( 399 + {isPlaceholderProfile ? ( 465 400 <LoadingPlaceholder 466 401 width="100%" 467 402 height={150} ··· 622 557 {invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`} 623 558 </ThemedText> 624 559 </View> 625 - {!isProfilePreview && !blockHide && ( 560 + {!isPlaceholderProfile && !blockHide && ( 626 561 <> 627 562 <View style={styles.metricsLine} pointerEvents="box-none"> 628 563 <Link ··· 737 672 </View> 738 673 ) 739 674 } 740 - ProfileHeaderLoaded = memo(ProfileHeaderLoaded) 675 + ProfileHeader = memo(ProfileHeader) 676 + export {ProfileHeader} 741 677 742 678 const styles = StyleSheet.create({ 743 679 banner: { ··· 845 781 846 782 br40: {borderRadius: 40}, 847 783 br50: {borderRadius: 50}, 848 - 849 - loadingBorderStyle: { 850 - borderLeftWidth: 1, 851 - borderRightWidth: 1, 852 - }, 853 784 })
+4 -1
src/view/com/util/UserAvatar.tsx
··· 123 123 usePlainRNImage = false, 124 124 }: UserAvatarProps): React.ReactNode => { 125 125 const pal = usePalette('default') 126 + const backgroundColor = pal.colors.backgroundLight 126 127 127 128 const aviStyle = useMemo(() => { 128 129 if (type === 'algo' || type === 'list') { ··· 130 131 width: size, 131 132 height: size, 132 133 borderRadius: size > 32 ? 8 : 3, 134 + backgroundColor, 133 135 } 134 136 } 135 137 return { 136 138 width: size, 137 139 height: size, 138 140 borderRadius: Math.floor(size / 2), 141 + backgroundColor, 139 142 } 140 - }, [type, size]) 143 + }, [type, size, backgroundColor]) 141 144 142 145 const alert = useMemo(() => { 143 146 if (!moderation?.alert) {
+57 -11
src/view/screens/Profile.tsx
··· 1 1 import React, {useMemo} from 'react' 2 2 import {StyleSheet, View} from 'react-native' 3 3 import {useFocusEffect} from '@react-navigation/native' 4 - import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' 4 + import { 5 + AppBskyActorDefs, 6 + moderateProfile, 7 + ModerationOpts, 8 + RichText as RichTextAPI, 9 + } from '@atproto/api' 5 10 import {msg, Trans} from '@lingui/macro' 6 11 import {useLingui} from '@lingui/react' 7 12 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' ··· 11 16 import {Feed} from 'view/com/posts/Feed' 12 17 import {ProfileLists} from '../com/lists/ProfileLists' 13 18 import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' 14 - import {ProfileHeader} from '../com/profile/ProfileHeader' 19 + import {ProfileHeader, ProfileHeaderLoading} from '../com/profile/ProfileHeader' 15 20 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' 16 21 import {ErrorScreen} from '../com/util/error/ErrorScreen' 17 22 import {EmptyState} from '../com/util/EmptyState' ··· 28 33 import {useResolveDidQuery} from '#/state/queries/resolve-uri' 29 34 import {useProfileQuery} from '#/state/queries/profile' 30 35 import {useProfileShadow} from '#/state/cache/profile-shadow' 31 - import {useSession} from '#/state/session' 36 + import {useSession, getAgent} from '#/state/session' 32 37 import {useModerationOpts} from '#/state/queries/preferences' 33 38 import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info' 34 39 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' ··· 87 92 }, [profile?.viewer?.blockedBy, resolvedDid]) 88 93 89 94 // Most pushes will happen here, since we will have only placeholder data 90 - if (isLoadingDid || isLoadingProfile || isPlaceholderProfile) { 95 + if (isLoadingDid || isLoadingProfile) { 91 96 return ( 92 97 <CenteredView> 93 - <ProfileHeader 94 - profile={profile ?? null} 95 - moderationOpts={moderationOpts ?? null} 96 - isProfilePreview={true} 97 - /> 98 + <ProfileHeaderLoading /> 98 99 </CenteredView> 99 100 ) 100 101 } ··· 114 115 <ProfileScreenLoaded 115 116 profile={profile} 116 117 moderationOpts={moderationOpts} 118 + isPlaceholderProfile={isPlaceholderProfile} 117 119 hideBackButton={!!route.params.hideBackButton} 118 120 /> 119 121 ) ··· 132 134 133 135 function ProfileScreenLoaded({ 134 136 profile: profileUnshadowed, 137 + isPlaceholderProfile, 135 138 moderationOpts, 136 139 hideBackButton, 137 140 }: { 138 141 profile: AppBskyActorDefs.ProfileViewDetailed 139 142 moderationOpts: ModerationOpts 140 143 hideBackButton: boolean 144 + isPlaceholderProfile: boolean 141 145 }) { 142 146 const profile = useProfileShadow(profileUnshadowed) 143 147 const {hasSession, currentAccount} = useSession() ··· 157 161 158 162 useSetTitle(combinedDisplayName(profile)) 159 163 164 + const description = profile.description ?? '' 165 + const hasDescription = description !== '' 166 + const [descriptionRT, isResolvingDescriptionRT] = useRichText(description) 167 + const showPlaceholder = isPlaceholderProfile || isResolvingDescriptionRT 160 168 const moderation = useMemo( 161 169 () => moderateProfile(profile, moderationOpts), 162 170 [profile, moderationOpts], ··· 270 278 return ( 271 279 <ProfileHeader 272 280 profile={profile} 281 + descriptionRT={hasDescription ? descriptionRT : null} 273 282 moderationOpts={moderationOpts} 274 283 hideBackButton={hideBackButton} 284 + isPlaceholderProfile={showPlaceholder} 275 285 /> 276 286 ) 277 - }, [profile, moderationOpts, hideBackButton]) 287 + }, [ 288 + profile, 289 + descriptionRT, 290 + hasDescription, 291 + moderationOpts, 292 + hideBackButton, 293 + showPlaceholder, 294 + ]) 278 295 279 296 return ( 280 297 <ScreenHider ··· 284 301 moderation={moderation.account}> 285 302 <PagerWithHeader 286 303 testID="profilePager" 287 - isHeaderReady={true} 304 + isHeaderReady={!showPlaceholder} 288 305 items={sectionTitles} 289 306 onPageSelected={onPageSelected} 290 307 onCurrentPageSelected={onCurrentPageSelected} ··· 439 456 </Text> 440 457 </View> 441 458 ) 459 + } 460 + 461 + function useRichText(text: string): [RichTextAPI, boolean] { 462 + const [prevText, setPrevText] = React.useState(text) 463 + const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text})) 464 + const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null) 465 + if (text !== prevText) { 466 + setPrevText(text) 467 + setRawRT(new RichTextAPI({text})) 468 + setResolvedRT(null) 469 + // This will queue an immediate re-render 470 + } 471 + React.useEffect(() => { 472 + let ignore = false 473 + async function resolveRTFacets() { 474 + // new each time 475 + const resolvedRT = new RichTextAPI({text}) 476 + await resolvedRT.detectFacets(getAgent()) 477 + if (!ignore) { 478 + setResolvedRT(resolvedRT) 479 + } 480 + } 481 + resolveRTFacets() 482 + return () => { 483 + ignore = true 484 + } 485 + }, [text]) 486 + const isResolving = resolvedRT === null 487 + return [resolvedRT ?? rawRT, isResolving] 442 488 } 443 489 444 490 const styles = StyleSheet.create({