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
zio/stable
dan 2024-02-09 05:00:50 +00:00 committed by GitHub
parent 0d00c7d851
commit d36b91fe67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 141 additions and 135 deletions

View File

@ -22,15 +22,15 @@ export interface ProfileShadow {
blockingUri: string | undefined blockingUri: string | undefined
} }
type ProfileView = const shadows: WeakMap<
| AppBskyActorDefs.ProfileView AppBskyActorDefs.ProfileView,
| AppBskyActorDefs.ProfileViewBasic Partial<ProfileShadow>
| AppBskyActorDefs.ProfileViewDetailed > = new WeakMap()
const shadows: WeakMap<ProfileView, Partial<ProfileShadow>> = new WeakMap()
const emitter = new EventEmitter() const emitter = new EventEmitter()
export function useProfileShadow(profile: ProfileView): Shadow<ProfileView> { export function useProfileShadow<
TProfileView extends AppBskyActorDefs.ProfileView,
>(profile: TProfileView): Shadow<TProfileView> {
const [shadow, setShadow] = useState(() => shadows.get(profile)) const [shadow, setShadow] = useState(() => shadows.get(profile))
const [prevPost, setPrevPost] = useState(profile) const [prevPost, setPrevPost] = useState(profile)
if (profile !== prevPost) { if (profile !== prevPost) {
@ -70,10 +70,10 @@ export function updateProfileShadow(
}) })
} }
function mergeShadow( function mergeShadow<TProfileView extends AppBskyActorDefs.ProfileView>(
profile: ProfileView, profile: TProfileView,
shadow: Partial<ProfileShadow>, shadow: Partial<ProfileShadow>,
): Shadow<ProfileView> { ): Shadow<TProfileView> {
return castAsShadow({ return castAsShadow({
...profile, ...profile,
viewer: { viewer: {
@ -89,7 +89,9 @@ function mergeShadow(
}) })
} }
function* findProfilesInCache(did: string): Generator<ProfileView, void> { function* findProfilesInCache(
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
yield* findAllProfilesInListMembersQueryData(queryClient, did) yield* findAllProfilesInListMembersQueryData(queryClient, did)
yield* findAllProfilesInMyBlockedAccountsQueryData(queryClient, did) yield* findAllProfilesInMyBlockedAccountsQueryData(queryClient, did)
yield* findAllProfilesInMyMutedAccountsQueryData(queryClient, did) yield* findAllProfilesInMyMutedAccountsQueryData(queryClient, did)

View File

@ -61,25 +61,21 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
const headerHeight = headerOnlyHeight + tabBarHeight const headerHeight = headerOnlyHeight + tabBarHeight
// capture the header bar sizing // capture the header bar sizing
const onTabBarLayout = React.useCallback( const onTabBarLayout = useNonReactiveCallback((evt: LayoutChangeEvent) => {
const height = evt.nativeEvent.layout.height
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS
setTabBarHeight(Math.round(height))
}
})
const onHeaderOnlyLayout = useNonReactiveCallback(
(evt: LayoutChangeEvent) => { (evt: LayoutChangeEvent) => {
const height = evt.nativeEvent.layout.height const height = evt.nativeEvent.layout.height
if (height > 0) { if (height > 0 && isHeaderReady) {
// The rounding is necessary to prevent jumps on iOS
setTabBarHeight(Math.round(height))
}
},
[setTabBarHeight],
)
const onHeaderOnlyLayout = React.useCallback(
(evt: LayoutChangeEvent) => {
const height = evt.nativeEvent.layout.height
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS // The rounding is necessary to prevent jumps on iOS
setHeaderOnlyHeight(Math.round(height)) setHeaderOnlyHeight(Math.round(height))
} }
}, },
[setHeaderOnlyHeight],
) )
const renderTabBar = React.useCallback( const renderTabBar = React.useCallback(

View File

@ -31,6 +31,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
children, children,
testID, testID,
items, items,
isHeaderReady,
renderHeader, renderHeader,
initialPage, initialPage,
onPageSelected, onPageSelected,
@ -46,6 +47,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
<PagerTabBar <PagerTabBar
items={items} items={items}
renderHeader={renderHeader} renderHeader={renderHeader}
isHeaderReady={isHeaderReady}
currentPage={currentPage} currentPage={currentPage}
onCurrentPageSelected={onCurrentPageSelected} onCurrentPageSelected={onCurrentPageSelected}
onSelect={props.onSelect} onSelect={props.onSelect}
@ -54,7 +56,14 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
/> />
) )
}, },
[items, renderHeader, currentPage, onCurrentPageSelected, testID], [
items,
isHeaderReady,
renderHeader,
currentPage,
onCurrentPageSelected,
testID,
],
) )
const onPageSelectedInner = React.useCallback( const onPageSelectedInner = React.useCallback(
@ -80,8 +89,14 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
{toArray(children) {toArray(children)
.filter(Boolean) .filter(Boolean)
.map((child, i) => { .map((child, i) => {
const isReady = isHeaderReady
return ( return (
<View key={i} collapsable={false}> <View
key={i}
collapsable={false}
style={{
display: isReady ? undefined : 'none',
}}>
<PagerItem isFocused={i === currentPage} renderTab={child} /> <PagerItem isFocused={i === currentPage} renderTab={child} />
</View> </View>
) )
@ -94,6 +109,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
let PagerTabBar = ({ let PagerTabBar = ({
currentPage, currentPage,
items, items,
isHeaderReady,
testID, testID,
renderHeader, renderHeader,
onCurrentPageSelected, onCurrentPageSelected,
@ -104,6 +120,7 @@ let PagerTabBar = ({
items: string[] items: string[]
testID?: string testID?: string
renderHeader?: () => JSX.Element renderHeader?: () => JSX.Element
isHeaderReady: boolean
onCurrentPageSelected?: (index: number) => void onCurrentPageSelected?: (index: number) => void
onSelect?: (index: number) => void onSelect?: (index: number) => void
tabBarAnchor?: JSX.Element | null | undefined tabBarAnchor?: JSX.Element | null | undefined
@ -112,7 +129,12 @@ let PagerTabBar = ({
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
return ( return (
<> <>
<View style={[!isMobile && styles.headerContainerDesktop, pal.border]}> <View
style={[
!isMobile && styles.headerContainerDesktop,
pal.border,
!isHeaderReady && styles.loadingHeader,
]}>
{renderHeader?.()} {renderHeader?.()}
</View> </View>
{tabBarAnchor} {tabBarAnchor}
@ -123,6 +145,9 @@ let PagerTabBar = ({
? styles.tabBarContainerMobile ? styles.tabBarContainerMobile
: styles.tabBarContainerDesktop, : styles.tabBarContainerDesktop,
pal.border, pal.border,
{
display: isHeaderReady ? undefined : 'none',
},
]}> ]}>
<TabBar <TabBar
testID={testID} testID={testID}
@ -183,6 +208,9 @@ const styles = StyleSheet.create({
paddingLeft: 14, paddingLeft: 14,
paddingRight: 14, paddingRight: 14,
}, },
loadingHeader: {
borderColor: 'transparent',
},
}) })
function toArray<T>(v: T | T[]): T[] { function toArray<T>(v: T | T[]): T[] {

View File

@ -51,76 +51,47 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
import {shareUrl} from 'lib/sharing' import {shareUrl} from 'lib/sharing'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useSession, getAgent} from '#/state/session' import {useSession} from '#/state/session'
import {Shadow} from '#/state/cache/types' import {Shadow} from '#/state/cache/types'
import {useRequireAuth} from '#/state/session' import {useRequireAuth} from '#/state/session'
import {LabelInfo} from '../util/moderation/LabelInfo' import {LabelInfo} from '../util/moderation/LabelInfo'
import {useProfileShadow} from 'state/cache/profile-shadow' import {useProfileShadow} from 'state/cache/profile-shadow'
interface Props { let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
profile: AppBskyActorDefs.ProfileView | null
placeholderData?: AppBskyActorDefs.ProfileView | null
moderationOpts: ModerationOpts | null
hideBackButton?: boolean
isProfilePreview?: boolean
}
export function ProfileHeader({
profile,
moderationOpts,
hideBackButton = false,
isProfilePreview,
}: Props) {
const pal = usePalette('default') const pal = usePalette('default')
return (
// loading <View style={pal.view}>
// = <LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} />
if (!profile || !moderationOpts) { <View
return ( style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
<View style={pal.view}> <LoadingPlaceholder width={80} height={80} style={styles.br40} />
<LoadingPlaceholder </View>
width="100%" <View style={styles.content}>
height={150} <View style={[styles.buttonsLine]}>
style={{borderRadius: 0}} <LoadingPlaceholder width={167} height={31} style={styles.br50} />
/>
<View
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
<LoadingPlaceholder width={80} height={80} style={styles.br40} />
</View>
<View style={styles.content}>
<View style={[styles.buttonsLine]}>
<LoadingPlaceholder width={167} height={31} style={styles.br50} />
</View>
</View> </View>
</View> </View>
) </View>
}
// loaded
// =
return (
<ProfileHeaderLoaded
profile={profile}
moderationOpts={moderationOpts}
hideBackButton={hideBackButton}
isProfilePreview={isProfilePreview}
/>
) )
} }
ProfileHeaderLoading = memo(ProfileHeaderLoading)
export {ProfileHeaderLoading}
interface LoadedProps { interface Props {
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
descriptionRT: RichTextAPI | null
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
hideBackButton?: boolean hideBackButton?: boolean
isProfilePreview?: boolean isPlaceholderProfile?: boolean
} }
let ProfileHeaderLoaded = ({ let ProfileHeader = ({
profile: profileUnshadowed, profile: profileUnshadowed,
descriptionRT,
moderationOpts, moderationOpts,
hideBackButton = false, hideBackButton = false,
isProfilePreview, isPlaceholderProfile,
}: LoadedProps): React.ReactNode => { }: Props): React.ReactNode => {
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
useProfileShadow(profileUnshadowed) useProfileShadow(profileUnshadowed)
const pal = usePalette('default') const pal = usePalette('default')
@ -144,37 +115,6 @@ let ProfileHeaderLoaded = ({
[profile, moderationOpts], [profile, moderationOpts],
) )
/*
* BEGIN handle bio facet resolution
*/
// should be undefined on first render to trigger a resolution
const prevProfileDescription = React.useRef<string | undefined>()
const [descriptionRT, setDescriptionRT] = React.useState<
RichTextAPI | undefined
>(
profile.description
? new RichTextAPI({text: profile.description})
: undefined,
)
React.useEffect(() => {
async function resolveRTFacets() {
// new each time
const rt = new RichTextAPI({text: profile.description || ''})
await rt.detectFacets(getAgent())
// replace existing RT instance
setDescriptionRT(rt)
}
if (profile.description !== prevProfileDescription.current) {
// update prev immediately
prevProfileDescription.current = profile.description
resolveRTFacets()
}
}, [profile.description, setDescriptionRT])
/*
* END handle bio facet resolution
*/
const invalidateProfileQuery = React.useCallback(() => { const invalidateProfileQuery = React.useCallback(() => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: profileQueryKey(profile.did), queryKey: profileQueryKey(profile.did),
@ -454,14 +394,9 @@ let ProfileHeaderLoaded = ({
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
return ( return (
<View <View style={[pal.view]} pointerEvents="box-none">
style={[
pal.view,
isProfilePreview && isDesktop && styles.loadingBorderStyle,
]}
pointerEvents="box-none">
<View pointerEvents="none"> <View pointerEvents="none">
{isProfilePreview ? ( {isPlaceholderProfile ? (
<LoadingPlaceholder <LoadingPlaceholder
width="100%" width="100%"
height={150} height={150}
@ -622,7 +557,7 @@ let ProfileHeaderLoaded = ({
{invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`} {invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`}
</ThemedText> </ThemedText>
</View> </View>
{!isProfilePreview && !blockHide && ( {!isPlaceholderProfile && !blockHide && (
<> <>
<View style={styles.metricsLine} pointerEvents="box-none"> <View style={styles.metricsLine} pointerEvents="box-none">
<Link <Link
@ -737,7 +672,8 @@ let ProfileHeaderLoaded = ({
</View> </View>
) )
} }
ProfileHeaderLoaded = memo(ProfileHeaderLoaded) ProfileHeader = memo(ProfileHeader)
export {ProfileHeader}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
banner: { banner: {
@ -845,9 +781,4 @@ const styles = StyleSheet.create({
br40: {borderRadius: 40}, br40: {borderRadius: 40},
br50: {borderRadius: 50}, br50: {borderRadius: 50},
loadingBorderStyle: {
borderLeftWidth: 1,
borderRightWidth: 1,
},
}) })

View File

@ -123,6 +123,7 @@ let UserAvatar = ({
usePlainRNImage = false, usePlainRNImage = false,
}: UserAvatarProps): React.ReactNode => { }: UserAvatarProps): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
const backgroundColor = pal.colors.backgroundLight
const aviStyle = useMemo(() => { const aviStyle = useMemo(() => {
if (type === 'algo' || type === 'list') { if (type === 'algo' || type === 'list') {
@ -130,14 +131,16 @@ let UserAvatar = ({
width: size, width: size,
height: size, height: size,
borderRadius: size > 32 ? 8 : 3, borderRadius: size > 32 ? 8 : 3,
backgroundColor,
} }
} }
return { return {
width: size, width: size,
height: size, height: size,
borderRadius: Math.floor(size / 2), borderRadius: Math.floor(size / 2),
backgroundColor,
} }
}, [type, size]) }, [type, size, backgroundColor])
const alert = useMemo(() => { const alert = useMemo(() => {
if (!moderation?.alert) { if (!moderation?.alert) {

View File

@ -1,7 +1,12 @@
import React, {useMemo} from 'react' import React, {useMemo} from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' import {
AppBskyActorDefs,
moderateProfile,
ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
@ -11,7 +16,7 @@ import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
import {Feed} from 'view/com/posts/Feed' import {Feed} from 'view/com/posts/Feed'
import {ProfileLists} from '../com/lists/ProfileLists' import {ProfileLists} from '../com/lists/ProfileLists'
import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens'
import {ProfileHeader} from '../com/profile/ProfileHeader' import {ProfileHeader, ProfileHeaderLoading} from '../com/profile/ProfileHeader'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ErrorScreen} from '../com/util/error/ErrorScreen'
import {EmptyState} from '../com/util/EmptyState' import {EmptyState} from '../com/util/EmptyState'
@ -28,7 +33,7 @@ import {
import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session' import {useSession, getAgent} from '#/state/session'
import {useModerationOpts} from '#/state/queries/preferences' import {useModerationOpts} from '#/state/queries/preferences'
import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info' import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
@ -87,14 +92,10 @@ export function ProfileScreen({route}: Props) {
}, [profile?.viewer?.blockedBy, resolvedDid]) }, [profile?.viewer?.blockedBy, resolvedDid])
// Most pushes will happen here, since we will have only placeholder data // Most pushes will happen here, since we will have only placeholder data
if (isLoadingDid || isLoadingProfile || isPlaceholderProfile) { if (isLoadingDid || isLoadingProfile) {
return ( return (
<CenteredView> <CenteredView>
<ProfileHeader <ProfileHeaderLoading />
profile={profile ?? null}
moderationOpts={moderationOpts ?? null}
isProfilePreview={true}
/>
</CenteredView> </CenteredView>
) )
} }
@ -114,6 +115,7 @@ export function ProfileScreen({route}: Props) {
<ProfileScreenLoaded <ProfileScreenLoaded
profile={profile} profile={profile}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
isPlaceholderProfile={isPlaceholderProfile}
hideBackButton={!!route.params.hideBackButton} hideBackButton={!!route.params.hideBackButton}
/> />
) )
@ -132,12 +134,14 @@ export function ProfileScreen({route}: Props) {
function ProfileScreenLoaded({ function ProfileScreenLoaded({
profile: profileUnshadowed, profile: profileUnshadowed,
isPlaceholderProfile,
moderationOpts, moderationOpts,
hideBackButton, hideBackButton,
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
hideBackButton: boolean hideBackButton: boolean
isPlaceholderProfile: boolean
}) { }) {
const profile = useProfileShadow(profileUnshadowed) const profile = useProfileShadow(profileUnshadowed)
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
@ -157,6 +161,10 @@ function ProfileScreenLoaded({
useSetTitle(combinedDisplayName(profile)) useSetTitle(combinedDisplayName(profile))
const description = profile.description ?? ''
const hasDescription = description !== ''
const [descriptionRT, isResolvingDescriptionRT] = useRichText(description)
const showPlaceholder = isPlaceholderProfile || isResolvingDescriptionRT
const moderation = useMemo( const moderation = useMemo(
() => moderateProfile(profile, moderationOpts), () => moderateProfile(profile, moderationOpts),
[profile, moderationOpts], [profile, moderationOpts],
@ -270,11 +278,20 @@ function ProfileScreenLoaded({
return ( return (
<ProfileHeader <ProfileHeader
profile={profile} profile={profile}
descriptionRT={hasDescription ? descriptionRT : null}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
hideBackButton={hideBackButton} hideBackButton={hideBackButton}
isPlaceholderProfile={showPlaceholder}
/> />
) )
}, [profile, moderationOpts, hideBackButton]) }, [
profile,
descriptionRT,
hasDescription,
moderationOpts,
hideBackButton,
showPlaceholder,
])
return ( return (
<ScreenHider <ScreenHider
@ -284,7 +301,7 @@ function ProfileScreenLoaded({
moderation={moderation.account}> moderation={moderation.account}>
<PagerWithHeader <PagerWithHeader
testID="profilePager" testID="profilePager"
isHeaderReady={true} isHeaderReady={!showPlaceholder}
items={sectionTitles} items={sectionTitles}
onPageSelected={onPageSelected} onPageSelected={onPageSelected}
onCurrentPageSelected={onCurrentPageSelected} onCurrentPageSelected={onCurrentPageSelected}
@ -441,6 +458,35 @@ function ProfileEndOfFeed() {
) )
} }
function useRichText(text: string): [RichTextAPI, boolean] {
const [prevText, setPrevText] = React.useState(text)
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null)
if (text !== prevText) {
setPrevText(text)
setRawRT(new RichTextAPI({text}))
setResolvedRT(null)
// This will queue an immediate re-render
}
React.useEffect(() => {
let ignore = false
async function resolveRTFacets() {
// new each time
const resolvedRT = new RichTextAPI({text})
await resolvedRT.detectFacets(getAgent())
if (!ignore) {
setResolvedRT(resolvedRT)
}
}
resolveRTFacets()
return () => {
ignore = true
}
}, [text])
const isResolving = resolvedRT === null
return [resolvedRT ?? rawRT, isResolving]
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flexDirection: 'column', flexDirection: 'column',