diff options
-rw-r--r-- | src/state/cache/profile-shadow.ts | 24 | ||||
-rw-r--r-- | src/view/com/pager/PagerWithHeader.tsx | 22 | ||||
-rw-r--r-- | src/view/com/pager/PagerWithHeader.web.tsx | 34 | ||||
-rw-r--r-- | src/view/com/profile/ProfileHeader.tsx | 123 | ||||
-rw-r--r-- | src/view/com/util/UserAvatar.tsx | 5 | ||||
-rw-r--r-- | 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 79a1f228e..34fe5995d 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<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) diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 7e9ed24db..31abc1ab7 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -61,25 +61,21 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( const headerHeight = headerOnlyHeight + tabBarHeight // capture the header bar sizing - const onTabBarLayout = React.useCallback( - (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( + 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) { + 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 4f959d548..9c63c149f 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<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[] { diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 2e80ca808..8fd50fad6 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 ( - <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, - }, }) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 00ff7e1ec..f673db1ee 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 9ca1b8c05..64e067593 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 ( <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', |