From d36b91fe67225a9d3c79c8eeb3c80f6a72e9f73f Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 9 Feb 2024 05:00:50 +0000 Subject: [PATCH] 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 --- src/state/cache/profile-shadow.ts | 24 ++-- src/view/com/pager/PagerWithHeader.tsx | 22 ++-- src/view/com/pager/PagerWithHeader.web.tsx | 34 +++++- src/view/com/profile/ProfileHeader.tsx | 123 +++++---------------- src/view/com/util/UserAvatar.tsx | 5 +- src/view/screens/Profile.tsx | 68 ++++++++++-- 6 files changed, 141 insertions(+), 135 deletions(-) diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index 79a1f228..34fe5995 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -22,15 +22,15 @@ export interface ProfileShadow { blockingUri: string | undefined } -type ProfileView = - | AppBskyActorDefs.ProfileView - | AppBskyActorDefs.ProfileViewBasic - | AppBskyActorDefs.ProfileViewDetailed - -const shadows: WeakMap> = new WeakMap() +const shadows: WeakMap< + AppBskyActorDefs.ProfileView, + Partial +> = new WeakMap() const emitter = new EventEmitter() -export function useProfileShadow(profile: ProfileView): Shadow { +export function useProfileShadow< + TProfileView extends AppBskyActorDefs.ProfileView, +>(profile: TProfileView): Shadow { 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( + profile: TProfileView, shadow: Partial, -): Shadow { +): Shadow { return castAsShadow({ ...profile, viewer: { @@ -89,7 +89,9 @@ function mergeShadow( }) } -function* findProfilesInCache(did: string): Generator { +function* findProfilesInCache( + did: string, +): Generator { yield* findAllProfilesInListMembersQueryData(queryClient, did) yield* findAllProfilesInMyBlockedAccountsQueryData(queryClient, did) yield* findAllProfilesInMyMutedAccountsQueryData(queryClient, did) diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 7e9ed24d..31abc1ab 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -61,25 +61,21 @@ export const PagerWithHeader = React.forwardRef( 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( diff --git a/src/view/com/pager/PagerWithHeader.web.tsx b/src/view/com/pager/PagerWithHeader.web.tsx index 4f959d54..9c63c149 100644 --- a/src/view/com/pager/PagerWithHeader.web.tsx +++ b/src/view/com/pager/PagerWithHeader.web.tsx @@ -31,6 +31,7 @@ export const PagerWithHeader = React.forwardRef( children, testID, items, + isHeaderReady, renderHeader, initialPage, onPageSelected, @@ -46,6 +47,7 @@ export const PagerWithHeader = React.forwardRef( ( /> ) }, - [items, renderHeader, currentPage, onCurrentPageSelected, testID], + [ + items, + isHeaderReady, + renderHeader, + currentPage, + onCurrentPageSelected, + testID, + ], ) const onPageSelectedInner = React.useCallback( @@ -80,8 +89,14 @@ export const PagerWithHeader = React.forwardRef( {toArray(children) .filter(Boolean) .map((child, i) => { + const isReady = isHeaderReady return ( - + ) @@ -94,6 +109,7 @@ export const PagerWithHeader = React.forwardRef( 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 ( <> - + {renderHeader?.()} {tabBarAnchor} @@ -123,6 +145,9 @@ let PagerTabBar = ({ ? styles.tabBarContainerMobile : styles.tabBarContainerDesktop, pal.border, + { + display: isHeaderReady ? undefined : 'none', + }, ]}> (v: T | T[]): T[] { diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 2e80ca80..8fd50fad 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -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 ( - - - - - - - - - + return ( + + + + + + + + - ) - } - - // loaded - // = - return ( - + ) } +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 = 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() - 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 ( - + - {isProfilePreview ? ( + {isPlaceholderProfile ? ( - {!isProfilePreview && !blockHide && ( + {!isPlaceholderProfile && !blockHide && ( <> ) } -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, - }, }) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 00ff7e1e..f673db1e 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -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) { diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 9ca1b8c0..64e06759 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -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 ( - + ) } @@ -114,6 +115,7 @@ export function ProfileScreen({route}: Props) { ) @@ -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 ( ) - }, [profile, moderationOpts, hideBackButton]) + }, [ + profile, + descriptionRT, + hasDescription, + moderationOpts, + hideBackButton, + showPlaceholder, + ]) return ( new RichTextAPI({text})) + const [resolvedRT, setResolvedRT] = React.useState(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',