Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Precache basic profile from posts for instant future navigations (#2795)

* skeleton for caching

* modify some existing logic

* refactor uri resolution query

* add precache feed posts

* adjustments

* remove prefetch on hover (maybe revert, just example)

* fix

* change arg name to match what we want

* optional infinite stale time

* use `ProfileViewDetailed`

* Revert "remove prefetch on hover (maybe revert, just example)"

This reverts commit 08609deb0defa7cea040438bc37dd3488ddc56f4.

* add warning comment back for stale time

* remove comment

* store profile with both the handle and did for query key

* remove extra block from revert

* clarify argument name

* remove QT cache

* structure queries the same (put `enabled` at bottom)

* use both `ProfileViewDetailed` and `ProfileView` for the query return type

* placeholder profile header

* remove logs

* remove a few other things we don't need

* add placeholder

* refactor

* refactor

* we don't need this height adjustment now

* use gray banner while loading

* set background color of image to the loading placeholder color

* reorg imports

* add border to header on loading

* Fix style

* Rm radius

* oops

* Undo edit

* Back out type changes

* Tighten some types and moderate shadow

* Move precaching fns to profile where the cache is

* Rename functions to match what they do now

* Remove anys

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>

authored by

Hailey
Dan Abramov
and committed by
GitHub
de286260 d9b62955

+170 -85
+2 -2
src/state/queries/notifications/util.ts
··· 12 12 import chunk from 'lodash.chunk' 13 13 import {QueryClient} from '@tanstack/react-query' 14 14 import {getAgent} from '../../session' 15 - import {precacheProfile as precacheResolvedUri} from '../resolve-uri' 15 + import {precacheProfile} from '../profile' 16 16 import {NotificationType, FeedNotification, FeedPage} from './types' 17 17 18 18 const GROUPABLE_REASONS = ['like', 'repost', 'follow'] ··· 59 59 if (notif.subjectUri) { 60 60 notif.subject = subjects.get(notif.subjectUri) 61 61 if (notif.subject) { 62 - precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution 62 + precacheProfile(queryClient, notif.subject.author) 63 63 } 64 64 } 65 65 }
+2 -2
src/state/queries/post-feed.ts
··· 21 21 import {HomeFeedAPI} from '#/lib/api/feed/home' 22 22 import {logger} from '#/logger' 23 23 import {STALE} from '#/state/queries' 24 - import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri' 24 + import {precacheFeedPostProfiles} from './profile' 25 25 import {getAgent} from '#/state/session' 26 26 import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' 27 27 import {getModerationOpts} from '#/state/queries/preferences/moderation' ··· 138 138 } 139 139 140 140 const res = await api.fetch({cursor, limit: PAGE_SIZE}) 141 - precacheResolvedUris(queryClient, res.feed) // precache the handle->did resolution 141 + precacheFeedPostProfiles(queryClient, res.feed) 142 142 143 143 /* 144 144 * If this is a public view, we need to check if posts fail moderation.
+2 -2
src/state/queries/post-thread.ts
··· 10 10 import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' 11 11 import {findPostInQueryData as findPostInFeedQueryData} from './post-feed' 12 12 import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed' 13 - import {precacheThreadPosts as precacheResolvedUris} from './resolve-uri' 13 + import {precacheThreadPostProfiles} from './profile' 14 14 import {getEmbeddedPost} from './util' 15 15 16 16 export const RQKEY = (uri: string) => ['post-thread', uri] ··· 71 71 const res = await getAgent().getPostThread({uri: uri!}) 72 72 if (res.success) { 73 73 const nodes = responseToThreadNodes(res.data.thread) 74 - precacheResolvedUris(queryClient, nodes) // precache the handle->did resolution 74 + precacheThreadPostProfiles(queryClient, nodes) 75 75 return nodes 76 76 } 77 77 return {type: 'unknown', uri: uri!}
+77 -3
src/state/queries/profile.ts
··· 4 4 AppBskyActorDefs, 5 5 AppBskyActorProfile, 6 6 AppBskyActorGetProfile, 7 + AppBskyFeedDefs, 8 + AppBskyEmbedRecord, 9 + AppBskyEmbedRecordWithMedia, 7 10 } from '@atproto/api' 8 11 import { 9 12 useQuery, ··· 23 26 import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' 24 27 import {STALE} from '#/state/queries' 25 28 import {track} from '#/lib/analytics/analytics' 29 + import {ThreadNode} from './post-thread' 26 30 27 31 export const RQKEY = (did: string) => ['profile', did] 28 32 export const profilesQueryKey = (handles: string[]) => ['profiles', handles] 33 + export const profileBasicQueryKey = (didOrHandle: string) => [ 34 + 'profileBasic', 35 + didOrHandle, 36 + ] 29 37 30 38 export function useProfileQuery({ 31 39 did, ··· 34 42 did: string | undefined 35 43 staleTime?: number 36 44 }) { 37 - return useQuery({ 45 + const queryClient = useQueryClient() 46 + return useQuery<AppBskyActorDefs.ProfileViewDetailed>({ 38 47 // WARNING 39 48 // this staleTime is load-bearing 40 49 // if you remove it, the UI infinite-loops 41 50 // -prf 42 51 staleTime, 43 52 refetchOnWindowFocus: true, 44 - queryKey: RQKEY(did || ''), 53 + queryKey: RQKEY(did ?? ''), 45 54 queryFn: async () => { 46 - const res = await getAgent().getProfile({actor: did || ''}) 55 + const res = await getAgent().getProfile({actor: did ?? ''}) 47 56 return res.data 57 + }, 58 + placeholderData: () => { 59 + if (!did) return 60 + 61 + return queryClient.getQueryData<AppBskyActorDefs.ProfileViewBasic>( 62 + profileBasicQueryKey(did), 63 + ) 48 64 }, 49 65 enabled: !!did, 50 66 }) ··· 403 419 resetProfilePostsQueries(did, 1000) 404 420 }, 405 421 }) 422 + } 423 + 424 + export function precacheProfile( 425 + queryClient: QueryClient, 426 + profile: AppBskyActorDefs.ProfileViewBasic, 427 + ) { 428 + queryClient.setQueryData(profileBasicQueryKey(profile.handle), profile) 429 + queryClient.setQueryData(profileBasicQueryKey(profile.did), profile) 430 + } 431 + 432 + export function precacheFeedPostProfiles( 433 + queryClient: QueryClient, 434 + posts: AppBskyFeedDefs.FeedViewPost[], 435 + ) { 436 + for (const post of posts) { 437 + // Save the author of the post every time 438 + precacheProfile(queryClient, post.post.author) 439 + precachePostEmbedProfile(queryClient, post.post.embed) 440 + 441 + // Cache parent author and embeds 442 + const parent = post.reply?.parent 443 + if (AppBskyFeedDefs.isPostView(parent)) { 444 + precacheProfile(queryClient, parent.author) 445 + precachePostEmbedProfile(queryClient, parent.embed) 446 + } 447 + } 448 + } 449 + 450 + function precachePostEmbedProfile( 451 + queryClient: QueryClient, 452 + embed: AppBskyFeedDefs.PostView['embed'], 453 + ) { 454 + if (AppBskyEmbedRecord.isView(embed)) { 455 + if (AppBskyEmbedRecord.isViewRecord(embed.record)) { 456 + precacheProfile(queryClient, embed.record.author) 457 + } 458 + } else if (AppBskyEmbedRecordWithMedia.isView(embed)) { 459 + if (AppBskyEmbedRecord.isViewRecord(embed.record.record)) { 460 + precacheProfile(queryClient, embed.record.record.author) 461 + } 462 + } 463 + } 464 + 465 + export function precacheThreadPostProfiles( 466 + queryClient: QueryClient, 467 + node: ThreadNode, 468 + ) { 469 + if (node.type === 'post') { 470 + precacheProfile(queryClient, node.post.author) 471 + if (node.parent) { 472 + precacheThreadPostProfiles(queryClient, node.parent) 473 + } 474 + if (node.replies?.length) { 475 + for (const reply of node.replies) { 476 + precacheThreadPostProfiles(queryClient, reply) 477 + } 478 + } 479 + } 406 480 } 407 481 408 482 async function whenAppViewReady(
+23 -49
src/state/queries/resolve-uri.ts
··· 1 - import {QueryClient, useQuery, UseQueryResult} from '@tanstack/react-query' 2 - import {AtUri, AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' 1 + import {useQuery, useQueryClient, UseQueryResult} from '@tanstack/react-query' 2 + import {AtUri, AppBskyActorDefs} from '@atproto/api' 3 3 4 + import {profileBasicQueryKey as RQKEY_PROFILE_BASIC} from './profile' 4 5 import {getAgent} from '#/state/session' 5 6 import {STALE} from '#/state/queries' 6 - import {ThreadNode} from './post-thread' 7 7 8 8 export const RQKEY = (didOrHandle: string) => ['resolved-did', didOrHandle] 9 9 ··· 22 22 } 23 23 24 24 export function useResolveDidQuery(didOrHandle: string | undefined) { 25 + const queryClient = useQueryClient() 26 + 25 27 return useQuery<string, Error>({ 26 28 staleTime: STALE.HOURS.ONE, 27 - queryKey: RQKEY(didOrHandle || ''), 28 - async queryFn() { 29 - if (!didOrHandle) { 30 - return '' 31 - } 32 - if (!didOrHandle.startsWith('did:')) { 33 - const res = await getAgent().resolveHandle({handle: didOrHandle}) 34 - didOrHandle = res.data.did 35 - } 36 - return didOrHandle 29 + queryKey: RQKEY(didOrHandle ?? ''), 30 + queryFn: async () => { 31 + if (!didOrHandle) return '' 32 + // Just return the did if it's already one 33 + if (didOrHandle.startsWith('did:')) return didOrHandle 34 + 35 + const res = await getAgent().resolveHandle({handle: didOrHandle}) 36 + return res.data.did 37 + }, 38 + initialData: () => { 39 + // Return undefined if no did or handle 40 + if (!didOrHandle) return 41 + 42 + const profile = 43 + queryClient.getQueryData<AppBskyActorDefs.ProfileViewBasic>( 44 + RQKEY_PROFILE_BASIC(didOrHandle), 45 + ) 46 + return profile?.did 37 47 }, 38 48 enabled: !!didOrHandle, 39 49 }) 40 50 } 41 - 42 - export function precacheProfile( 43 - queryClient: QueryClient, 44 - profile: 45 - | AppBskyActorDefs.ProfileView 46 - | AppBskyActorDefs.ProfileViewBasic 47 - | AppBskyActorDefs.ProfileViewDetailed, 48 - ) { 49 - queryClient.setQueryData(RQKEY(profile.handle), profile.did) 50 - } 51 - 52 - export function precacheFeedPosts( 53 - queryClient: QueryClient, 54 - posts: AppBskyFeedDefs.FeedViewPost[], 55 - ) { 56 - for (const post of posts) { 57 - precacheProfile(queryClient, post.post.author) 58 - } 59 - } 60 - 61 - export function precacheThreadPosts( 62 - queryClient: QueryClient, 63 - node: ThreadNode, 64 - ) { 65 - if (node.type === 'post') { 66 - precacheProfile(queryClient, node.post.author) 67 - if (node.parent) { 68 - precacheThreadPosts(queryClient, node.parent) 69 - } 70 - if (node.replies?.length) { 71 - for (const reply of node.replies) { 72 - precacheThreadPosts(queryClient, reply) 73 - } 74 - } 75 - } 76 - }
+49 -19
src/view/com/profile/ProfileHeader.tsx
··· 1 - import React, {memo} from 'react' 1 + import React, {memo, useMemo} from 'react' 2 2 import { 3 3 StyleSheet, 4 4 TouchableOpacity, ··· 10 10 import {useQueryClient} from '@tanstack/react-query' 11 11 import { 12 12 AppBskyActorDefs, 13 - ProfileModeration, 13 + ModerationOpts, 14 + moderateProfile, 14 15 RichText as RichTextAPI, 15 16 } from '@atproto/api' 16 17 import {Trans, msg} from '@lingui/macro' ··· 42 43 import {useAnalytics} from 'lib/analytics/analytics' 43 44 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 44 45 import {BACK_HITSLOP} from 'lib/constants' 45 - import {isInvalidHandle} from 'lib/strings/handles' 46 + import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles' 46 47 import {makeProfileLink} from 'lib/routes/links' 47 48 import {pluralize} from 'lib/strings/helpers' 48 49 import {toShareUrl} from 'lib/strings/url-helpers' 49 50 import {sanitizeDisplayName} from 'lib/strings/display-names' 50 - import {sanitizeHandle} from 'lib/strings/handles' 51 51 import {shareUrl} from 'lib/sharing' 52 52 import {s, colors} from 'lib/styles' 53 53 import {logger} from '#/logger' ··· 55 55 import {Shadow} from '#/state/cache/types' 56 56 import {useRequireAuth} from '#/state/session' 57 57 import {LabelInfo} from '../util/moderation/LabelInfo' 58 + import {useProfileShadow} from 'state/cache/profile-shadow' 58 59 59 60 interface Props { 60 - profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> | null 61 - moderation: ProfileModeration | null 61 + profile: AppBskyActorDefs.ProfileView | null 62 + placeholderData?: AppBskyActorDefs.ProfileView | null 63 + moderationOpts: ModerationOpts | null 62 64 hideBackButton?: boolean 63 65 isProfilePreview?: boolean 64 66 } 65 67 66 68 export function ProfileHeader({ 67 69 profile, 68 - moderation, 70 + moderationOpts, 69 71 hideBackButton = false, 70 72 isProfilePreview, 71 73 }: Props) { ··· 73 75 74 76 // loading 75 77 // = 76 - if (!profile || !moderation) { 78 + if (!profile || !moderationOpts) { 77 79 return ( 78 80 <View style={pal.view}> 79 - <LoadingPlaceholder width="100%" height={153} /> 81 + <LoadingPlaceholder 82 + width="100%" 83 + height={150} 84 + style={{borderRadius: 0}} 85 + /> 80 86 <View 81 87 style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> 82 88 <LoadingPlaceholder width={80} height={80} style={styles.br40} /> ··· 95 101 return ( 96 102 <ProfileHeaderLoaded 97 103 profile={profile} 98 - moderation={moderation} 104 + moderationOpts={moderationOpts} 99 105 hideBackButton={hideBackButton} 100 106 isProfilePreview={isProfilePreview} 101 107 /> ··· 103 109 } 104 110 105 111 interface LoadedProps { 106 - profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 107 - moderation: ProfileModeration 112 + profile: AppBskyActorDefs.ProfileViewDetailed 113 + moderationOpts: ModerationOpts 108 114 hideBackButton?: boolean 109 115 isProfilePreview?: boolean 110 116 } 111 117 112 118 let ProfileHeaderLoaded = ({ 113 - profile, 114 - moderation, 119 + profile: profileUnshadowed, 120 + moderationOpts, 115 121 hideBackButton = false, 116 122 isProfilePreview, 117 123 }: LoadedProps): React.ReactNode => { 124 + const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = 125 + useProfileShadow(profileUnshadowed) 118 126 const pal = usePalette('default') 119 127 const palInverted = usePalette('inverted') 120 128 const {currentAccount, hasSession} = useSession() ··· 131 139 const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) 132 140 const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 133 141 const queryClient = useQueryClient() 142 + const moderation = useMemo( 143 + () => moderateProfile(profile, moderationOpts), 144 + [profile, moderationOpts], 145 + ) 134 146 135 147 /* 136 148 * BEGIN handle bio facet resolution ··· 442 454 const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') 443 455 444 456 return ( 445 - <View style={pal.view} pointerEvents="box-none"> 457 + <View 458 + style={[ 459 + pal.view, 460 + isProfilePreview && isDesktop && styles.loadingBorderStyle, 461 + ]} 462 + pointerEvents="box-none"> 446 463 <View pointerEvents="none"> 447 - <UserBanner banner={profile.banner} moderation={moderation.avatar} /> 464 + {isProfilePreview ? ( 465 + <LoadingPlaceholder 466 + width="100%" 467 + height={150} 468 + style={{borderRadius: 0}} 469 + /> 470 + ) : ( 471 + <UserBanner banner={profile.banner} moderation={moderation.avatar} /> 472 + )} 448 473 </View> 449 474 <View style={styles.content} pointerEvents="box-none"> 450 475 <View style={[styles.buttonsLine]} pointerEvents="box-none"> ··· 478 503 ) 479 504 ) : !profile.viewer?.blockedBy ? ( 480 505 <> 481 - {!isProfilePreview && hasSession && ( 506 + {hasSession && ( 482 507 <TouchableOpacity 483 508 testID="suggestedFollowsBtn" 484 509 onPress={() => setShowSuggestedFollows(!showSuggestedFollows)} ··· 597 622 {invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`} 598 623 </ThemedText> 599 624 </View> 600 - {!blockHide && ( 625 + {!isProfilePreview && !blockHide && ( 601 626 <> 602 627 <View style={styles.metricsLine} pointerEvents="box-none"> 603 628 <Link ··· 665 690 )} 666 691 </View> 667 692 668 - {!isProfilePreview && showSuggestedFollows && ( 693 + {showSuggestedFollows && ( 669 694 <ProfileHeaderSuggestedFollows 670 695 actorDid={profile.did} 671 696 requestDismiss={() => { ··· 820 845 821 846 br40: {borderRadius: 40}, 822 847 br50: {borderRadius: 50}, 848 + 849 + loadingBorderStyle: { 850 + borderLeftWidth: 1, 851 + borderRightWidth: 1, 852 + }, 823 853 })
+8 -3
src/view/com/util/UserBanner.tsx
··· 3 3 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 4 import {ModerationUI} from '@atproto/api' 5 5 import {Image} from 'expo-image' 6 + import {useLingui} from '@lingui/react' 7 + import {msg} from '@lingui/macro' 6 8 import {colors} from 'lib/styles' 9 + import {useTheme} from 'lib/ThemeContext' 7 10 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' 8 11 import { 9 12 usePhotoLibraryPermission, ··· 13 16 import {isWeb, isAndroid} from 'platform/detection' 14 17 import {Image as RNImage} from 'react-native-image-crop-picker' 15 18 import {NativeDropdown, DropdownItem} from './forms/NativeDropdown' 16 - import {useLingui} from '@lingui/react' 17 - import {msg} from '@lingui/macro' 18 19 19 20 export function UserBanner({ 20 21 banner, ··· 26 27 onSelectNewBanner?: (img: RNImage | null) => void 27 28 }) { 28 29 const pal = usePalette('default') 30 + const theme = useTheme() 29 31 const {_} = useLingui() 30 32 const {requestCameraAccessIfNeeded} = useCameraPermission() 31 33 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() ··· 142 144 !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( 143 145 <Image 144 146 testID="userBannerImage" 145 - style={styles.bannerImage} 147 + style={[ 148 + styles.bannerImage, 149 + {backgroundColor: theme.palette.default.backgroundLight}, 150 + ]} 146 151 resizeMode="cover" 147 152 source={{uri: banner}} 148 153 blurRadius={moderation?.blur ? 100 : 0}
+7 -5
src/view/screens/Profile.tsx
··· 66 66 error: profileError, 67 67 refetch: refetchProfile, 68 68 isLoading: isLoadingProfile, 69 + isPlaceholderData: isPlaceholderProfile, 69 70 } = useProfileQuery({ 70 71 did: resolvedDid, 71 72 }) ··· 85 86 } 86 87 }, [profile?.viewer?.blockedBy, resolvedDid]) 87 88 88 - if (isLoadingDid || isLoadingProfile || !moderationOpts) { 89 + // Most pushes will happen here, since we will have only placeholder data 90 + if (isLoadingDid || isLoadingProfile || isPlaceholderProfile) { 89 91 return ( 90 92 <CenteredView> 91 93 <ProfileHeader 92 - profile={null} 93 - moderation={null} 94 + profile={profile ?? null} 95 + moderationOpts={moderationOpts ?? null} 94 96 isProfilePreview={true} 95 97 /> 96 98 </CenteredView> ··· 268 270 return ( 269 271 <ProfileHeader 270 272 profile={profile} 271 - moderation={moderation} 273 + moderationOpts={moderationOpts} 272 274 hideBackButton={hideBackButton} 273 275 /> 274 276 ) 275 - }, [profile, moderation, hideBackButton]) 277 + }, [profile, moderationOpts, hideBackButton]) 276 278 277 279 return ( 278 280 <ScreenHider