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 unusedzio/stable
parent
0d00c7d851
commit
d36b91fe67
|
@ -22,15 +22,15 @@ export interface ProfileShadow {
|
|||
blockingUri: string | undefined
|
||||
}
|
||||
|
||||
type ProfileView =
|
||||
| AppBskyActorDefs.ProfileView
|
||||
| AppBskyActorDefs.ProfileViewBasic
|
||||
| AppBskyActorDefs.ProfileViewDetailed
|
||||
|
||||
const shadows: WeakMap<ProfileView, Partial<ProfileShadow>> = new WeakMap()
|
||||
const shadows: WeakMap<
|
||||
AppBskyActorDefs.ProfileView,
|
||||
Partial<ProfileShadow>
|
||||
> = new WeakMap()
|
||||
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 [prevPost, setPrevPost] = useState(profile)
|
||||
if (profile !== prevPost) {
|
||||
|
@ -70,10 +70,10 @@ export function updateProfileShadow(
|
|||
})
|
||||
}
|
||||
|
||||
function mergeShadow(
|
||||
profile: ProfileView,
|
||||
function mergeShadow<TProfileView extends AppBskyActorDefs.ProfileView>(
|
||||
profile: TProfileView,
|
||||
shadow: Partial<ProfileShadow>,
|
||||
): Shadow<ProfileView> {
|
||||
): Shadow<TProfileView> {
|
||||
return castAsShadow({
|
||||
...profile,
|
||||
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* findAllProfilesInMyBlockedAccountsQueryData(queryClient, did)
|
||||
yield* findAllProfilesInMyMutedAccountsQueryData(queryClient, did)
|
||||
|
|
|
@ -61,25 +61,21 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
|||
const headerHeight = headerOnlyHeight + tabBarHeight
|
||||
|
||||
// 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) => {
|
||||
const height = evt.nativeEvent.layout.height
|
||||
if (height > 0) {
|
||||
// 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) {
|
||||
if (height > 0 && isHeaderReady) {
|
||||
// The rounding is necessary to prevent jumps on iOS
|
||||
setHeaderOnlyHeight(Math.round(height))
|
||||
}
|
||||
},
|
||||
[setHeaderOnlyHeight],
|
||||
)
|
||||
|
||||
const renderTabBar = React.useCallback(
|
||||
|
|
|
@ -31,6 +31,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
|||
children,
|
||||
testID,
|
||||
items,
|
||||
isHeaderReady,
|
||||
renderHeader,
|
||||
initialPage,
|
||||
onPageSelected,
|
||||
|
@ -46,6 +47,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
|||
<PagerTabBar
|
||||
items={items}
|
||||
renderHeader={renderHeader}
|
||||
isHeaderReady={isHeaderReady}
|
||||
currentPage={currentPage}
|
||||
onCurrentPageSelected={onCurrentPageSelected}
|
||||
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(
|
||||
|
@ -80,8 +89,14 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
|||
{toArray(children)
|
||||
.filter(Boolean)
|
||||
.map((child, i) => {
|
||||
const isReady = isHeaderReady
|
||||
return (
|
||||
<View key={i} collapsable={false}>
|
||||
<View
|
||||
key={i}
|
||||
collapsable={false}
|
||||
style={{
|
||||
display: isReady ? undefined : 'none',
|
||||
}}>
|
||||
<PagerItem isFocused={i === currentPage} renderTab={child} />
|
||||
</View>
|
||||
)
|
||||
|
@ -94,6 +109,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
|||
let PagerTabBar = ({
|
||||
currentPage,
|
||||
items,
|
||||
isHeaderReady,
|
||||
testID,
|
||||
renderHeader,
|
||||
onCurrentPageSelected,
|
||||
|
@ -104,6 +120,7 @@ let PagerTabBar = ({
|
|||
items: string[]
|
||||
testID?: string
|
||||
renderHeader?: () => JSX.Element
|
||||
isHeaderReady: boolean
|
||||
onCurrentPageSelected?: (index: number) => void
|
||||
onSelect?: (index: number) => void
|
||||
tabBarAnchor?: JSX.Element | null | undefined
|
||||
|
@ -112,7 +129,12 @@ let PagerTabBar = ({
|
|||
const {isMobile} = useWebMediaQueries()
|
||||
return (
|
||||
<>
|
||||
<View style={[!isMobile && styles.headerContainerDesktop, pal.border]}>
|
||||
<View
|
||||
style={[
|
||||
!isMobile && styles.headerContainerDesktop,
|
||||
pal.border,
|
||||
!isHeaderReady && styles.loadingHeader,
|
||||
]}>
|
||||
{renderHeader?.()}
|
||||
</View>
|
||||
{tabBarAnchor}
|
||||
|
@ -123,6 +145,9 @@ let PagerTabBar = ({
|
|||
? styles.tabBarContainerMobile
|
||||
: styles.tabBarContainerDesktop,
|
||||
pal.border,
|
||||
{
|
||||
display: isHeaderReady ? undefined : 'none',
|
||||
},
|
||||
]}>
|
||||
<TabBar
|
||||
testID={testID}
|
||||
|
@ -183,6 +208,9 @@ const styles = StyleSheet.create({
|
|||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
},
|
||||
loadingHeader: {
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
})
|
||||
|
||||
function toArray<T>(v: T | T[]): T[] {
|
||||
|
|
|
@ -51,76 +51,47 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
|
|||
import {shareUrl} from 'lib/sharing'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {logger} from '#/logger'
|
||||
import {useSession, getAgent} from '#/state/session'
|
||||
import {useSession} from '#/state/session'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useRequireAuth} from '#/state/session'
|
||||
import {LabelInfo} from '../util/moderation/LabelInfo'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
|
||||
interface Props {
|
||||
profile: AppBskyActorDefs.ProfileView | null
|
||||
placeholderData?: AppBskyActorDefs.ProfileView | null
|
||||
moderationOpts: ModerationOpts | null
|
||||
hideBackButton?: boolean
|
||||
isProfilePreview?: boolean
|
||||
}
|
||||
|
||||
export function ProfileHeader({
|
||||
profile,
|
||||
moderationOpts,
|
||||
hideBackButton = false,
|
||||
isProfilePreview,
|
||||
}: Props) {
|
||||
let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
|
||||
// loading
|
||||
// =
|
||||
if (!profile || !moderationOpts) {
|
||||
return (
|
||||
<View style={pal.view}>
|
||||
<LoadingPlaceholder
|
||||
width="100%"
|
||||
height={150}
|
||||
style={{borderRadius: 0}}
|
||||
/>
|
||||
<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>
|
||||
return (
|
||||
<View style={pal.view}>
|
||||
<LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
// loaded
|
||||
// =
|
||||
return (
|
||||
<ProfileHeaderLoaded
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
hideBackButton={hideBackButton}
|
||||
isProfilePreview={isProfilePreview}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
ProfileHeaderLoading = memo(ProfileHeaderLoading)
|
||||
export {ProfileHeaderLoading}
|
||||
|
||||
interface LoadedProps {
|
||||
interface Props {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
descriptionRT: RichTextAPI | null
|
||||
moderationOpts: ModerationOpts
|
||||
hideBackButton?: boolean
|
||||
isProfilePreview?: boolean
|
||||
isPlaceholderProfile?: boolean
|
||||
}
|
||||
|
||||
let ProfileHeaderLoaded = ({
|
||||
let ProfileHeader = ({
|
||||
profile: profileUnshadowed,
|
||||
descriptionRT,
|
||||
moderationOpts,
|
||||
hideBackButton = false,
|
||||
isProfilePreview,
|
||||
}: LoadedProps): React.ReactNode => {
|
||||
isPlaceholderProfile,
|
||||
}: Props): React.ReactNode => {
|
||||
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
|
||||
useProfileShadow(profileUnshadowed)
|
||||
const pal = usePalette('default')
|
||||
|
@ -144,37 +115,6 @@ let ProfileHeaderLoaded = ({
|
|||
[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(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: profileQueryKey(profile.did),
|
||||
|
@ -454,14 +394,9 @@ let ProfileHeaderLoaded = ({
|
|||
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
pal.view,
|
||||
isProfilePreview && isDesktop && styles.loadingBorderStyle,
|
||||
]}
|
||||
pointerEvents="box-none">
|
||||
<View style={[pal.view]} pointerEvents="box-none">
|
||||
<View pointerEvents="none">
|
||||
{isProfilePreview ? (
|
||||
{isPlaceholderProfile ? (
|
||||
<LoadingPlaceholder
|
||||
width="100%"
|
||||
height={150}
|
||||
|
@ -622,7 +557,7 @@ let ProfileHeaderLoaded = ({
|
|||
{invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`}
|
||||
</ThemedText>
|
||||
</View>
|
||||
{!isProfilePreview && !blockHide && (
|
||||
{!isPlaceholderProfile && !blockHide && (
|
||||
<>
|
||||
<View style={styles.metricsLine} pointerEvents="box-none">
|
||||
<Link
|
||||
|
@ -737,7 +672,8 @@ let ProfileHeaderLoaded = ({
|
|||
</View>
|
||||
)
|
||||
}
|
||||
ProfileHeaderLoaded = memo(ProfileHeaderLoaded)
|
||||
ProfileHeader = memo(ProfileHeader)
|
||||
export {ProfileHeader}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
banner: {
|
||||
|
@ -845,9 +781,4 @@ const styles = StyleSheet.create({
|
|||
|
||||
br40: {borderRadius: 40},
|
||||
br50: {borderRadius: 50},
|
||||
|
||||
loadingBorderStyle: {
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -123,6 +123,7 @@ let UserAvatar = ({
|
|||
usePlainRNImage = false,
|
||||
}: UserAvatarProps): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
const backgroundColor = pal.colors.backgroundLight
|
||||
|
||||
const aviStyle = useMemo(() => {
|
||||
if (type === 'algo' || type === 'list') {
|
||||
|
@ -130,14 +131,16 @@ let UserAvatar = ({
|
|||
width: size,
|
||||
height: size,
|
||||
borderRadius: size > 32 ? 8 : 3,
|
||||
backgroundColor,
|
||||
}
|
||||
}
|
||||
return {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: Math.floor(size / 2),
|
||||
backgroundColor,
|
||||
}
|
||||
}, [type, size])
|
||||
}, [type, size, backgroundColor])
|
||||
|
||||
const alert = useMemo(() => {
|
||||
if (!moderation?.alert) {
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import React, {useMemo} from 'react'
|
||||
import {StyleSheet, View} from 'react-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 {useLingui} from '@lingui/react'
|
||||
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 {ProfileLists} from '../com/lists/ProfileLists'
|
||||
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 {ErrorScreen} from '../com/util/error/ErrorScreen'
|
||||
import {EmptyState} from '../com/util/EmptyState'
|
||||
|
@ -28,7 +33,7 @@ import {
|
|||
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
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 {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
|
@ -87,14 +92,10 @@ export function ProfileScreen({route}: Props) {
|
|||
}, [profile?.viewer?.blockedBy, resolvedDid])
|
||||
|
||||
// Most pushes will happen here, since we will have only placeholder data
|
||||
if (isLoadingDid || isLoadingProfile || isPlaceholderProfile) {
|
||||
if (isLoadingDid || isLoadingProfile) {
|
||||
return (
|
||||
<CenteredView>
|
||||
<ProfileHeader
|
||||
profile={profile ?? null}
|
||||
moderationOpts={moderationOpts ?? null}
|
||||
isProfilePreview={true}
|
||||
/>
|
||||
<ProfileHeaderLoading />
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
@ -114,6 +115,7 @@ export function ProfileScreen({route}: Props) {
|
|||
<ProfileScreenLoaded
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
isPlaceholderProfile={isPlaceholderProfile}
|
||||
hideBackButton={!!route.params.hideBackButton}
|
||||
/>
|
||||
)
|
||||
|
@ -132,12 +134,14 @@ export function ProfileScreen({route}: Props) {
|
|||
|
||||
function ProfileScreenLoaded({
|
||||
profile: profileUnshadowed,
|
||||
isPlaceholderProfile,
|
||||
moderationOpts,
|
||||
hideBackButton,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
hideBackButton: boolean
|
||||
isPlaceholderProfile: boolean
|
||||
}) {
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
|
@ -157,6 +161,10 @@ function ProfileScreenLoaded({
|
|||
|
||||
useSetTitle(combinedDisplayName(profile))
|
||||
|
||||
const description = profile.description ?? ''
|
||||
const hasDescription = description !== ''
|
||||
const [descriptionRT, isResolvingDescriptionRT] = useRichText(description)
|
||||
const showPlaceholder = isPlaceholderProfile || isResolvingDescriptionRT
|
||||
const moderation = useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
[profile, moderationOpts],
|
||||
|
@ -270,11 +278,20 @@ function ProfileScreenLoaded({
|
|||
return (
|
||||
<ProfileHeader
|
||||
profile={profile}
|
||||
descriptionRT={hasDescription ? descriptionRT : null}
|
||||
moderationOpts={moderationOpts}
|
||||
hideBackButton={hideBackButton}
|
||||
isPlaceholderProfile={showPlaceholder}
|
||||
/>
|
||||
)
|
||||
}, [profile, moderationOpts, hideBackButton])
|
||||
}, [
|
||||
profile,
|
||||
descriptionRT,
|
||||
hasDescription,
|
||||
moderationOpts,
|
||||
hideBackButton,
|
||||
showPlaceholder,
|
||||
])
|
||||
|
||||
return (
|
||||
<ScreenHider
|
||||
|
@ -284,7 +301,7 @@ function ProfileScreenLoaded({
|
|||
moderation={moderation.account}>
|
||||
<PagerWithHeader
|
||||
testID="profilePager"
|
||||
isHeaderReady={true}
|
||||
isHeaderReady={!showPlaceholder}
|
||||
items={sectionTitles}
|
||||
onPageSelected={onPageSelected}
|
||||
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({
|
||||
container: {
|
||||
flexDirection: 'column',
|
||||
|
|
Loading…
Reference in New Issue