Skip to content

Commit e34cbec

Browse files
committed
Add backfill-in-progress indicator to profile header pills row
When Blacksky's indexed counts are significantly lower than Bluesky's canonical counts, show a pill badge inline with moderation labels. Clicking it opens a modal explaining the backfill process. Detection: badge shown when any count differs by >5% AND >10 absolute. Data source: unauthenticated fetch from public.api.bsky.app.
1 parent 5146a5b commit e34cbec

3 files changed

Lines changed: 161 additions & 20 deletions

File tree

src/lib/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ export const urls = {
205205
},
206206
}
207207

208+
export const PUBLIC_BSKY_API = 'https://public.api.bsky.app'
208209
export const PUBLIC_APPVIEW = 'https://api.blacksky.community'
209210
export const PUBLIC_APPVIEW_DID = 'did:web:api.blacksky.community'
210211
export const PUBLIC_STAGING_APPVIEW_DID = 'did:web:api.staging.bsky.dev'

src/screens/Profile/Header/Shell.tsx

Lines changed: 131 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,35 @@ import Animated, {
1010
import {useSafeAreaInsets} from 'react-native-safe-area-context'
1111
import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api'
1212
import {utils} from '@bsky.app/alf'
13-
import {msg} from '@lingui/macro'
13+
import {msg, Trans} from '@lingui/macro'
1414
import {useLingui} from '@lingui/react'
1515
import {useNavigation} from '@react-navigation/native'
1616

1717
import {useActorStatus} from '#/lib/actor-status'
1818
import {BACK_HITSLOP} from '#/lib/constants'
1919
import {useHaptics} from '#/lib/haptics'
20+
import {getModerationCauseKey, unique} from '#/lib/moderation'
2021
import {type NavigationProp} from '#/lib/routes/types'
2122
import {type Shadow} from '#/state/cache/types'
2223
import {useLightboxControls} from '#/state/lightbox'
24+
import {usePublicProfileQuery} from '#/state/queries/profile'
2325
import {useSession} from '#/state/session'
2426
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
2527
import {UserAvatar} from '#/view/com/util/UserAvatar'
2628
import {UserBanner} from '#/view/com/util/UserBanner'
2729
import {atoms as a, platform, useTheme} from '#/alf'
30+
import {colors} from '#/components/Admonition'
2831
import {Button} from '#/components/Button'
2932
import {useDialogControl} from '#/components/Dialog'
3033
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
34+
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateIcon} from '#/components/icons/ArrowRotate'
3135
import {EditLiveDialog} from '#/components/live/EditLiveDialog'
3236
import {LiveIndicator} from '#/components/live/LiveIndicator'
3337
import {LiveStatusDialog} from '#/components/live/LiveStatusDialog'
3438
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
35-
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
39+
import * as Pills from '#/components/Pills'
40+
import * as Prompt from '#/components/Prompt'
41+
import {Text} from '#/components/Typography'
3642
import {useAnalytics} from '#/analytics'
3743
import {IS_IOS} from '#/env'
3844
import {GrowableAvatar} from './GrowableAvatar'
@@ -243,15 +249,7 @@ let ProfileHeaderShell = ({
243249
]}
244250
/>
245251
) : (
246-
<ProfileHeaderAlerts
247-
moderation={moderation}
248-
style={[
249-
a.px_lg,
250-
a.pt_xs,
251-
a.pb_sm,
252-
IS_IOS ? a.pointer_events_auto : {pointerEvents: 'box-none'},
253-
]}
254-
/>
252+
<ProfileHeaderPills profile={profile} moderation={moderation} />
255253
))}
256254

257255
<GrowableAvatar style={[a.absolute, {top: 104, left: 10}]}>
@@ -310,3 +308,125 @@ let ProfileHeaderShell = ({
310308

311309
ProfileHeaderShell = memo(ProfileHeaderShell)
312310
export {ProfileHeaderShell}
311+
312+
function ProfileHeaderPills({
313+
profile,
314+
moderation,
315+
}: {
316+
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
317+
moderation: ModerationDecision
318+
}) {
319+
const modui = moderation.ui('profileView')
320+
const {data: publicProfile} = usePublicProfileQuery({did: profile.did})
321+
322+
const isPartiallyBackfilled = useMemo(() => {
323+
if (!publicProfile) return false
324+
const THRESHOLD_PCT = 0.05
325+
const THRESHOLD_ABS = 10
326+
const isLower = (local: number, canonical: number) => {
327+
const diff = canonical - local
328+
return diff > THRESHOLD_ABS && diff > canonical * THRESHOLD_PCT
329+
}
330+
return (
331+
isLower(profile.followersCount ?? 0, publicProfile.followersCount ?? 0) ||
332+
isLower(profile.followsCount ?? 0, publicProfile.followsCount ?? 0) ||
333+
isLower(profile.postsCount ?? 0, publicProfile.postsCount ?? 0)
334+
)
335+
}, [profile, publicProfile])
336+
337+
const hasAlerts = modui.alert || modui.inform
338+
if (!isPartiallyBackfilled && !hasAlerts) return null
339+
340+
return (
341+
<Pills.Row
342+
size="lg"
343+
style={[
344+
a.px_lg,
345+
a.pt_xs,
346+
a.pb_sm,
347+
IS_IOS ? a.pointer_events_auto : {pointerEvents: 'box-none'},
348+
]}>
349+
{isPartiallyBackfilled && <BackfillPill />}
350+
{modui.alerts.filter(unique).map(cause => (
351+
<Pills.Label
352+
size="lg"
353+
key={getModerationCauseKey(cause)}
354+
cause={cause}
355+
/>
356+
))}
357+
{modui.informs.filter(unique).map(cause => (
358+
<Pills.Label
359+
size="lg"
360+
key={getModerationCauseKey(cause)}
361+
cause={cause}
362+
/>
363+
))}
364+
</Pills.Row>
365+
)
366+
}
367+
368+
function BackfillPill() {
369+
const t = useTheme()
370+
const {_} = useLingui()
371+
const control = Prompt.usePromptControl()
372+
373+
return (
374+
<>
375+
<Button
376+
label={_(msg`Backfill in progress`)}
377+
onPress={e => {
378+
e.preventDefault()
379+
e.stopPropagation()
380+
control.open()
381+
}}>
382+
{({hovered, pressed}) => (
383+
<View
384+
style={[
385+
a.flex_row,
386+
a.align_center,
387+
a.rounded_full,
388+
t.atoms.bg_contrast_25,
389+
(hovered || pressed) && t.atoms.bg_contrast_50,
390+
{
391+
gap: 5,
392+
paddingHorizontal: 5,
393+
paddingVertical: 5,
394+
},
395+
]}>
396+
<ArrowRotateIcon width={16} fill={colors.warning} />
397+
<Text
398+
style={[
399+
a.text_sm,
400+
a.font_semi_bold,
401+
a.leading_tight,
402+
t.atoms.text_contrast_medium,
403+
{paddingRight: 3},
404+
]}>
405+
<Trans>Backfill in progress</Trans>
406+
</Text>
407+
</View>
408+
)}
409+
</Button>
410+
411+
<Prompt.Outer control={control}>
412+
<Prompt.Content>
413+
<Prompt.TitleText>
414+
<Trans>Backfill in progress</Trans>
415+
</Prompt.TitleText>
416+
<Prompt.DescriptionText>
417+
<Trans>
418+
Blacksky is still indexing this account's data from the AT
419+
Protocol network. The follower, following, and post counts shown
420+
may be lower than the actual totals. Relationship indicators (like
421+
whether this account follows you) may also be incomplete until
422+
backfill is finished.
423+
</Trans>
424+
</Prompt.DescriptionText>
425+
</Prompt.Content>
426+
<Prompt.Actions>
427+
<Prompt.Action cta={_(msg`Okay`)} onPress={() => {}} />
428+
</Prompt.Actions>
429+
</Prompt.Outer>
430+
</>
431+
)
432+
}

src/state/queries/profile.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,10 @@ import {
1010
type ComAtprotoRepoUploadBlob,
1111
type Un$Typed,
1212
} from '@atproto/api'
13-
import {
14-
type InfiniteData,
15-
keepPreviousData,
16-
prefetchQueryWithFallback,
17-
type QueryClient,
18-
useMutation,
19-
useQuery,
20-
useQueryClient,
21-
} from './useQueryWithFallback'
2213

2314
import {uploadBlob} from '#/lib/api'
2415
import {until} from '#/lib/async/until'
16+
import {PUBLIC_BSKY_API} from '#/lib/constants'
2517
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
2618
import {updateProfileShadow} from '#/state/cache/profile-shadow'
2719
import {type Shadow} from '#/state/cache/types'
@@ -46,6 +38,15 @@ import {
4638
import {RQKEY_ROOT as RQKEY_LIST_CONVOS} from './messages/list-conversations'
4739
import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts'
4840
import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
41+
import {
42+
type InfiniteData,
43+
keepPreviousData,
44+
prefetchQueryWithFallback,
45+
type QueryClient,
46+
useMutation,
47+
useQuery,
48+
useQueryClient,
49+
} from './useQueryWithFallback'
4950

5051
export * from '#/state/queries/unstable-profile-cache'
5152
/**
@@ -94,6 +95,25 @@ export function useProfileQuery({
9495
})
9596
}
9697

98+
export function usePublicProfileQuery({did}: {did: string | undefined}) {
99+
return useQuery<{
100+
followersCount?: number
101+
followsCount?: number
102+
postsCount?: number
103+
} | null>({
104+
staleTime: STALE.MINUTES.FIVE,
105+
queryKey: ['public-profile', did ?? ''],
106+
queryFn: async () => {
107+
const res = await fetch(
108+
`${PUBLIC_BSKY_API}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did ?? '')}`,
109+
)
110+
if (!res.ok) return null
111+
return res.json()
112+
},
113+
enabled: !!did,
114+
})
115+
}
116+
97117
export function useProfilesQuery({
98118
handles,
99119
maintainData,

0 commit comments

Comments
 (0)