diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index 1c85d2b6..626d3e91 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -12,7 +12,7 @@ import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import chunk from 'lodash.chunk' import {QueryClient} from '@tanstack/react-query' import {getAgent} from '../../session' -import {precacheProfile as precacheResolvedUri} from '../resolve-uri' +import {precacheProfile} from '../profile' import {NotificationType, FeedNotification, FeedPage} from './types' const GROUPABLE_REASONS = ['like', 'repost', 'follow'] @@ -59,7 +59,7 @@ export async function fetchPage({ if (notif.subjectUri) { notif.subject = subjects.get(notif.subjectUri) if (notif.subject) { - precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution + precacheProfile(queryClient, notif.subject.author) } } } diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index b422fa8f..32000908 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -21,7 +21,7 @@ import {MergeFeedAPI} from 'lib/api/feed/merge' import {HomeFeedAPI} from '#/lib/api/feed/home' import {logger} from '#/logger' import {STALE} from '#/state/queries' -import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri' +import {precacheFeedPostProfiles} from './profile' import {getAgent} from '#/state/session' import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' import {getModerationOpts} from '#/state/queries/preferences/moderation' @@ -138,7 +138,7 @@ export function usePostFeedQuery( } const res = await api.fetch({cursor, limit: PAGE_SIZE}) - precacheResolvedUris(queryClient, res.feed) // precache the handle->did resolution + precacheFeedPostProfiles(queryClient, res.feed) /* * If this is a public view, we need to check if posts fail moderation. diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index abb0fea1..ba424316 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -10,7 +10,7 @@ import {getAgent} from '#/state/session' import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {findPostInQueryData as findPostInFeedQueryData} from './post-feed' import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed' -import {precacheThreadPosts as precacheResolvedUris} from './resolve-uri' +import {precacheThreadPostProfiles} from './profile' import {getEmbeddedPost} from './util' export const RQKEY = (uri: string) => ['post-thread', uri] @@ -71,7 +71,7 @@ export function usePostThreadQuery(uri: string | undefined) { const res = await getAgent().getPostThread({uri: uri!}) if (res.success) { const nodes = responseToThreadNodes(res.data.thread) - precacheResolvedUris(queryClient, nodes) // precache the handle->did resolution + precacheThreadPostProfiles(queryClient, nodes) return nodes } return {type: 'unknown', uri: uri!} diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index affb8295..e81ea0f3 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -4,6 +4,9 @@ import { AppBskyActorDefs, AppBskyActorProfile, AppBskyActorGetProfile, + AppBskyFeedDefs, + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, } from '@atproto/api' import { useQuery, @@ -23,9 +26,14 @@ import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts' import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' import {STALE} from '#/state/queries' import {track} from '#/lib/analytics/analytics' +import {ThreadNode} from './post-thread' export const RQKEY = (did: string) => ['profile', did] export const profilesQueryKey = (handles: string[]) => ['profiles', handles] +export const profileBasicQueryKey = (didOrHandle: string) => [ + 'profileBasic', + didOrHandle, +] export function useProfileQuery({ did, @@ -34,18 +42,26 @@ export function useProfileQuery({ did: string | undefined staleTime?: number }) { - return useQuery({ + const queryClient = useQueryClient() + return useQuery({ // WARNING // this staleTime is load-bearing // if you remove it, the UI infinite-loops // -prf staleTime, refetchOnWindowFocus: true, - queryKey: RQKEY(did || ''), + queryKey: RQKEY(did ?? ''), queryFn: async () => { - const res = await getAgent().getProfile({actor: did || ''}) + const res = await getAgent().getProfile({actor: did ?? ''}) return res.data }, + placeholderData: () => { + if (!did) return + + return queryClient.getQueryData( + profileBasicQueryKey(did), + ) + }, enabled: !!did, }) } @@ -405,6 +421,64 @@ function useProfileUnblockMutation() { }) } +export function precacheProfile( + queryClient: QueryClient, + profile: AppBskyActorDefs.ProfileViewBasic, +) { + queryClient.setQueryData(profileBasicQueryKey(profile.handle), profile) + queryClient.setQueryData(profileBasicQueryKey(profile.did), profile) +} + +export function precacheFeedPostProfiles( + queryClient: QueryClient, + posts: AppBskyFeedDefs.FeedViewPost[], +) { + for (const post of posts) { + // Save the author of the post every time + precacheProfile(queryClient, post.post.author) + precachePostEmbedProfile(queryClient, post.post.embed) + + // Cache parent author and embeds + const parent = post.reply?.parent + if (AppBskyFeedDefs.isPostView(parent)) { + precacheProfile(queryClient, parent.author) + precachePostEmbedProfile(queryClient, parent.embed) + } + } +} + +function precachePostEmbedProfile( + queryClient: QueryClient, + embed: AppBskyFeedDefs.PostView['embed'], +) { + if (AppBskyEmbedRecord.isView(embed)) { + if (AppBskyEmbedRecord.isViewRecord(embed.record)) { + precacheProfile(queryClient, embed.record.author) + } + } else if (AppBskyEmbedRecordWithMedia.isView(embed)) { + if (AppBskyEmbedRecord.isViewRecord(embed.record.record)) { + precacheProfile(queryClient, embed.record.record.author) + } + } +} + +export function precacheThreadPostProfiles( + queryClient: QueryClient, + node: ThreadNode, +) { + if (node.type === 'post') { + precacheProfile(queryClient, node.post.author) + if (node.parent) { + precacheThreadPostProfiles(queryClient, node.parent) + } + if (node.replies?.length) { + for (const reply of node.replies) { + precacheThreadPostProfiles(queryClient, reply) + } + } + } +} + async function whenAppViewReady( actor: string, fn: (res: AppBskyActorGetProfile.Response) => boolean, diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts index a7599846..95fc867d 100644 --- a/src/state/queries/resolve-uri.ts +++ b/src/state/queries/resolve-uri.ts @@ -1,9 +1,9 @@ -import {QueryClient, useQuery, UseQueryResult} from '@tanstack/react-query' -import {AtUri, AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' +import {useQuery, useQueryClient, UseQueryResult} from '@tanstack/react-query' +import {AtUri, AppBskyActorDefs} from '@atproto/api' +import {profileBasicQueryKey as RQKEY_PROFILE_BASIC} from './profile' import {getAgent} from '#/state/session' import {STALE} from '#/state/queries' -import {ThreadNode} from './post-thread' export const RQKEY = (didOrHandle: string) => ['resolved-did', didOrHandle] @@ -22,55 +22,29 @@ export function useResolveUriQuery(uri: string | undefined): UriUseQueryResult { } export function useResolveDidQuery(didOrHandle: string | undefined) { + const queryClient = useQueryClient() + return useQuery({ staleTime: STALE.HOURS.ONE, - queryKey: RQKEY(didOrHandle || ''), - async queryFn() { - if (!didOrHandle) { - return '' - } - if (!didOrHandle.startsWith('did:')) { - const res = await getAgent().resolveHandle({handle: didOrHandle}) - didOrHandle = res.data.did - } - return didOrHandle + queryKey: RQKEY(didOrHandle ?? ''), + queryFn: async () => { + if (!didOrHandle) return '' + // Just return the did if it's already one + if (didOrHandle.startsWith('did:')) return didOrHandle + + const res = await getAgent().resolveHandle({handle: didOrHandle}) + return res.data.did + }, + initialData: () => { + // Return undefined if no did or handle + if (!didOrHandle) return + + const profile = + queryClient.getQueryData( + RQKEY_PROFILE_BASIC(didOrHandle), + ) + return profile?.did }, enabled: !!didOrHandle, }) } - -export function precacheProfile( - queryClient: QueryClient, - profile: - | AppBskyActorDefs.ProfileView - | AppBskyActorDefs.ProfileViewBasic - | AppBskyActorDefs.ProfileViewDetailed, -) { - queryClient.setQueryData(RQKEY(profile.handle), profile.did) -} - -export function precacheFeedPosts( - queryClient: QueryClient, - posts: AppBskyFeedDefs.FeedViewPost[], -) { - for (const post of posts) { - precacheProfile(queryClient, post.post.author) - } -} - -export function precacheThreadPosts( - queryClient: QueryClient, - node: ThreadNode, -) { - if (node.type === 'post') { - precacheProfile(queryClient, node.post.author) - if (node.parent) { - precacheThreadPosts(queryClient, node.parent) - } - if (node.replies?.length) { - for (const reply of node.replies) { - precacheThreadPosts(queryClient, reply) - } - } - } -} diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 0305dc26..1f5a76cf 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -1,4 +1,4 @@ -import React, {memo} from 'react' +import React, {memo, useMemo} from 'react' import { StyleSheet, TouchableOpacity, @@ -10,7 +10,8 @@ import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import { AppBskyActorDefs, - ProfileModeration, + ModerationOpts, + moderateProfile, RichText as RichTextAPI, } from '@atproto/api' import {Trans, msg} from '@lingui/macro' @@ -42,12 +43,11 @@ import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {BACK_HITSLOP} from 'lib/constants' -import {isInvalidHandle} from 'lib/strings/handles' +import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' import {pluralize} from 'lib/strings/helpers' import {toShareUrl} from 'lib/strings/url-helpers' import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' import {shareUrl} from 'lib/sharing' import {s, colors} from 'lib/styles' import {logger} from '#/logger' @@ -55,17 +55,19 @@ import {useSession, getAgent} 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: Shadow | null - moderation: ProfileModeration | null + profile: AppBskyActorDefs.ProfileView | null + placeholderData?: AppBskyActorDefs.ProfileView | null + moderationOpts: ModerationOpts | null hideBackButton?: boolean isProfilePreview?: boolean } export function ProfileHeader({ profile, - moderation, + moderationOpts, hideBackButton = false, isProfilePreview, }: Props) { @@ -73,10 +75,14 @@ export function ProfileHeader({ // loading // = - if (!profile || !moderation) { + if (!profile || !moderationOpts) { return ( - + @@ -95,7 +101,7 @@ export function ProfileHeader({ return ( @@ -103,18 +109,20 @@ export function ProfileHeader({ } interface LoadedProps { - profile: Shadow - moderation: ProfileModeration + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ModerationOpts hideBackButton?: boolean isProfilePreview?: boolean } let ProfileHeaderLoaded = ({ - profile, - moderation, + profile: profileUnshadowed, + moderationOpts, hideBackButton = false, isProfilePreview, }: LoadedProps): React.ReactNode => { + const profile: Shadow = + useProfileShadow(profileUnshadowed) const pal = usePalette('default') const palInverted = usePalette('inverted') const {currentAccount, hasSession} = useSession() @@ -131,6 +139,10 @@ let ProfileHeaderLoaded = ({ const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) const queryClient = useQueryClient() + const moderation = useMemo( + () => moderateProfile(profile, moderationOpts), + [profile, moderationOpts], + ) /* * BEGIN handle bio facet resolution @@ -442,9 +454,22 @@ let ProfileHeaderLoaded = ({ const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') return ( - + - + {isProfilePreview ? ( + + ) : ( + + )} @@ -478,7 +503,7 @@ let ProfileHeaderLoaded = ({ ) ) : !profile.viewer?.blockedBy ? ( <> - {!isProfilePreview && hasSession && ( + {hasSession && ( setShowSuggestedFollows(!showSuggestedFollows)} @@ -597,7 +622,7 @@ let ProfileHeaderLoaded = ({ {invalidHandle ? _(msg`āš Invalid Handle`) : `@${profile.handle}`} - {!blockHide && ( + {!isProfilePreview && !blockHide && ( <> - {!isProfilePreview && showSuggestedFollows && ( + {showSuggestedFollows && ( { @@ -820,4 +845,9 @@ const styles = StyleSheet.create({ br40: {borderRadius: 40}, br50: {borderRadius: 50}, + + loadingBorderStyle: { + borderLeftWidth: 1, + borderRightWidth: 1, + }, }) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index b31d7e55..cb47b665 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -3,7 +3,10 @@ import {StyleSheet, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {ModerationUI} from '@atproto/api' import {Image} from 'expo-image' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' import {colors} from 'lib/styles' +import {useTheme} from 'lib/ThemeContext' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import { usePhotoLibraryPermission, @@ -13,8 +16,6 @@ import {usePalette} from 'lib/hooks/usePalette' import {isWeb, isAndroid} from 'platform/detection' import {Image as RNImage} from 'react-native-image-crop-picker' import {NativeDropdown, DropdownItem} from './forms/NativeDropdown' -import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' export function UserBanner({ banner, @@ -26,6 +27,7 @@ export function UserBanner({ onSelectNewBanner?: (img: RNImage | null) => void }) { const pal = usePalette('default') + const theme = useTheme() const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() @@ -142,7 +144,10 @@ export function UserBanner({ !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( @@ -268,11 +270,11 @@ function ProfileScreenLoaded({ return ( ) - }, [profile, moderation, hideBackButton]) + }, [profile, moderationOpts, hideBackButton]) return (