Add invalid handle recovery dialog

When the signed-in account's handle comes back as handle.invalid, auto-open
a dialog (snoozed per account for 24h) that explains the problem and offers
a path to fix it:

- Refresh button: asks the server to re-resolve identity via
  com.atproto.identity.refreshIdentity, then re-syncs the session
- Server-resolution diagnostics: recovers the intended handle from the DID
  document (describeRepo alsoKnownAs) and resolves it to distinguish
  "resolves correctly now", "wrong DID", "not resolving", and
  service-provided handle issues
- Static FAQ of likely causes (missing/multiple TXT records, wrong DID,
  expired domain, propagation delay, well-known file)
- Handoff to the existing Change Handle dialog and a support link

The invalid handle pill on one's own profile header is now tappable to
reopen the dialog on demand, bypassing the snooze.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSXAgZis8iP5KmcUcGXCKw
This commit is contained in:
Claude
2026-07-03 19:40:29 +00:00
parent 79a16094f1
commit 780a1bac5c
11 changed files with 1036 additions and 33 deletions
+4
View File
@@ -17,6 +17,7 @@ export type StatefulControl<T> = {
type ControlsContext = {
mutedWordsDialogControl: Control
signinDialogControl: Control
invalidHandleDialogControl: Control
inAppBrowserConsentControl: StatefulControl<string>
emailDialogControl: StatefulControl<Screen>
linkWarningDialogControl: StatefulControl<{
@@ -44,6 +45,7 @@ export function useGlobalDialogsControlContext() {
export function Provider({children}: React.PropsWithChildren<{}>) {
const mutedWordsDialogControl = Dialog.useDialogControl()
const signinDialogControl = Dialog.useDialogControl()
const invalidHandleDialogControl = Dialog.useDialogControl()
const inAppBrowserConsentControl = useStatefulDialogControl<string>()
const emailDialogControl = useStatefulDialogControl<Screen>()
const linkWarningDialogControl = useStatefulDialogControl<{
@@ -61,6 +63,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
() => ({
mutedWordsDialogControl,
signinDialogControl,
invalidHandleDialogControl,
inAppBrowserConsentControl,
emailDialogControl,
linkWarningDialogControl,
@@ -70,6 +73,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[
mutedWordsDialogControl,
signinDialogControl,
invalidHandleDialogControl,
inAppBrowserConsentControl,
emailDialogControl,
linkWarningDialogControl,
@@ -0,0 +1,502 @@
import {useEffect, useRef, useState} from 'react'
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {FEEDBACK_FORM_URL} from '#/lib/constants'
import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {isInvalidHandle} from '#/lib/strings/handles'
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
import {useAgent, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell'
import {ChangeHandleDialog} from '#/screens/Settings/components/ChangeHandleDialog'
import {CopyButton} from '#/screens/Settings/components/CopyButton'
import {atoms as a, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RefreshIcon} from '#/components/icons/ArrowRotate'
import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At'
import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon,
ChevronTop_Stroke2_Corner0_Rounded as ChevronTopIcon,
} from '#/components/icons/Chevron'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as ExternalIcon} from '#/components/icons/SquareArrowTopRight'
import {SquareBehindSquare4_Stroke2_Corner0_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {isSnoozed, snooze} from '#/features/invalidHandle/snoozing'
import {type IdentityDiagnosis} from '#/features/invalidHandle/types'
import {useIdentityDiagnosticsQuery} from '#/features/invalidHandle/useDiagnosticsQuery'
import {useDevMode} from '#/storage/hooks/dev-mode'
/**
* Recovery dialog for accounts whose handle failed to verify and came back as
* `handle.invalid`. Auto-opens (snoozed per account) when the condition is
* detected on the current account, and can be reopened any time from the
* profile header's invalid handle pill.
*/
export function InvalidHandleDialog() {
const {invalidHandleDialogControl: control} = useGlobalDialogsControlContext()
const changeHandleControl = Dialog.useDialogControl()
const {hasSession, currentAccount} = useSession()
const onboardingActive = useOnboardingState().isActive
const did = currentAccount?.did
useEffect(() => {
if (!hasSession || !currentAccount) return
if (onboardingActive) return
if (!isInvalidHandle(currentAccount.handle)) return
if (isSnoozed(currentAccount.did)) return
control.open()
}, [hasSession, currentAccount, onboardingActive, control])
/*
* If the user switches accounts while the dialog is open, close it so we
* don't show diagnostics for the wrong account.
*/
const prevDid = useRef(did)
useEffect(() => {
if (prevDid.current !== did) {
prevDid.current = did
control.close()
}
}, [did, control])
return (
<>
<Dialog.Outer
control={control}
onClose={() => {
if (did) snooze(did)
}}>
<Dialog.Handle />
<InvalidHandleDialogInner
openChangeHandle={() => changeHandleControl.open()}
/>
</Dialog.Outer>
<ChangeHandleDialog control={changeHandleControl} />
</>
)
}
function InvalidHandleDialogInner({
openChangeHandle,
}: {
openChangeHandle: () => void
}) {
const control = Dialog.useDialogContext()
const {t: l} = useLingui()
const t = useTheme()
const agent = useAgent()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
const openLink = useOpenLink()
const [devMode] = useDevMode()
const {
data: report,
isPending: isDiagnosing,
refetch: rerunDiagnostics,
} = useIdentityDiagnosticsQuery({enabled: true})
const {
mutate: refresh,
isPending: isRefreshing,
data: refreshFixedHandle,
} = useMutation({
mutationFn: async () => {
try {
/*
* Ask the server to re-resolve our identity, busting its cache.
* Typed errors (HandleNotFound etc.) are expected while the handle is
* broken, and older PDS versions may not support the endpoint at all,
* so failures here should not abort the refresh.
*/
await agent.com.atproto.identity.refreshIdentity({
identifier: currentAccount!.did,
})
} catch {}
await agent.resumeSession(agent.session!)
return !isInvalidHandle(agent.session?.handle ?? 'handle.invalid')
},
onSuccess: fixed => {
if (currentAccount) {
void queryClient.invalidateQueries({
queryKey: RQKEY_PROFILE(currentAccount.did),
})
}
if (fixed) {
control.close(() => {
Toast.show(l`Your handle has been verified!`, {type: 'success'})
})
} else {
void rerunDiagnostics()
}
},
onError: () => {
Toast.show(l`Failed to refresh. Please try again.`, {type: 'error'})
},
})
const intendedHandle = report?.intendedHandle
const supportUrl = FEEDBACK_FORM_URL({
email: currentAccount?.email,
handle: intendedHandle ?? currentAccount?.handle,
})
return (
<Dialog.ScrollableInner label={l`Handle verification failed`}>
<View style={[a.gap_lg]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_2xl]}>
<Trans>We couldnt verify your handle</Trans>
</Text>
{intendedHandle ? (
<Text
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
Your account points to{' '}
<Text style={[a.text_md, a.font_bold]}>@{intendedHandle}</Text>,
but we couldnt confirm that this handle belongs to you. Until
its fixed, your handle will appear as invalid.
</Trans>
</Text>
) : (
<Text
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
We couldnt confirm that your handle belongs to you. Until its
fixed, your handle will appear as invalid.
</Trans>
</Text>
)}
</View>
{isDiagnosing ? (
<View style={[a.align_center, a.py_lg]}>
<Loader size="lg" />
</View>
) : report ? (
<DiagnosisMessage diagnosis={report.diagnosis} />
) : null}
{refreshFixedHandle === false && !isRefreshing && (
<Admonition type="warning">
<Trans>
Your handle is still not verified. If you just fixed your DNS
record, it can take a little while for changes to take effect
try again in a few minutes.
</Trans>
</Admonition>
)}
{report && <ExpectedRecordInfo diagnosis={report.diagnosis} />}
<Button
label={l`Refresh my handle`}
onPress={() => refresh()}
disabled={isRefreshing}
size="large"
color="primary">
{isRefreshing ? (
<ButtonIcon icon={Loader} />
) : (
<ButtonIcon icon={RefreshIcon} />
)}
<ButtonText>
<Trans>Refresh</Trans>
</ButtonText>
</Button>
<LikelyCauses />
<View style={[a.gap_sm]}>
<Button
label={l`Change my handle`}
onPress={() => control.close(() => openChangeHandle())}
size="large"
color="secondary">
<ButtonIcon icon={AtIcon} />
<ButtonText>
<Trans>Change my handle</Trans>
</ButtonText>
</Button>
<Button
label={l`Contact support`}
accessibilityHint={l`Opens helpdesk in browser`}
onPress={() => openLink(supportUrl)}
size="large"
variant="ghost"
color="secondary">
<ButtonText>
<Trans>Still stuck? Contact support</Trans>
</ButtonText>
<ButtonIcon icon={ExternalIcon} position="right" />
</Button>
</View>
{devMode && report && (
<View style={[a.gap_xs]}>
<CopyButton
value={JSON.stringify(report.raw, null, 2)}
label="Copy debug info"
size="small"
color="secondary"
shape="rectangular">
<Text style={[a.font_bold, a.text_xs, a.flex_1]}>Debug</Text>
<ButtonIcon icon={CopyIcon} />
</CopyButton>
<Text
style={[
a.text_xs,
a.leading_tight,
{fontFamily: 'monospace'},
t.atoms.text_contrast_low,
]}>
{JSON.stringify(report.raw, null, 2)}
</Text>
</View>
)}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function DiagnosisMessage({diagnosis}: {diagnosis: IdentityDiagnosis}) {
switch (diagnosis.type) {
case 'resolves-correctly':
return (
<Admonition type="info">
<Trans>
Good news your handle now appears to be set up correctly. Press
Refresh below to re-verify it.
</Trans>
</Admonition>
)
case 'wrong-did':
return (
<Admonition type="error">
<Trans>
<Text style={[a.font_bold]}>@{diagnosis.handle}</Text> currently
points to a different account ({diagnosis.found}). Update your DNS
TXT record or well-known file to contain this accounts DID, shown
below.
</Trans>
</Admonition>
)
case 'not-resolving':
return (
<Admonition type="warning">
<Trans>
<Text style={[a.font_bold]}>@{diagnosis.handle}</Text> isnt
resolving to your account. This usually means a problem with the
domains DNS record see the likely causes below.
</Trans>
</Admonition>
)
case 'service-handle-issue':
return (
<Admonition type="warning">
<Trans>
Your handle is provided by your hosting service, so this is likely a
temporary issue on the server. Try pressing Refresh, and contact
support if it doesnt resolve.
</Trans>
</Admonition>
)
case 'no-aka-handle':
return (
<Admonition type="error">
<Trans>
Your account doesnt declare a handle. Set a new one using the
Change my handle button below.
</Trans>
</Admonition>
)
case 'network-unavailable':
return (
<Admonition type="warning">
<Trans>
We couldnt run checks on your handle you appear to be offline.
</Trans>
</Admonition>
)
case 'inconclusive':
return (
<Admonition type="info">
<Trans>
We couldnt determine the exact cause from this device. See the
likely causes below.
</Trans>
</Admonition>
)
}
}
/**
* For diagnoses that point at a broken or missing record, show the exact
* value the user needs to publish, with a copy button.
*/
function ExpectedRecordInfo({diagnosis}: {diagnosis: IdentityDiagnosis}) {
const {t: l} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
if (
diagnosis.type !== 'wrong-did' &&
diagnosis.type !== 'not-resolving' &&
diagnosis.type !== 'inconclusive'
) {
return null
}
if (!currentAccount) return null
return (
<View style={[a.gap_sm]}>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>Your DNS TXT record value should be:</Trans>
</Text>
<View
style={[
a.rounded_sm,
a.p_md,
a.border,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
]}>
<CopyButton
color="secondary"
value={'did=' + currentAccount.did}
label={l`Copy TXT record value`}
style={[a.bg_transparent]}
hoverStyle={[a.bg_transparent]}>
<Text style={[a.text_md, a.flex_1]}>did={currentAccount.did}</Text>
<ButtonIcon icon={CopyIcon} />
</CopyButton>
</View>
</View>
)
}
function LikelyCauses() {
const t = useTheme()
const {t: l} = useLingui()
const {currentAccount} = useSession()
const did = currentAccount?.did ?? ''
return (
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_lg]}>
<Trans>Likely causes</Trans>
</Text>
<FaqItem title={l`The DNS record is missing`}>
<Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
For a custom domain handle, your domain must have a TXT record with
host <Text style={[a.font_bold]}>_atproto</Text> and value{' '}
<Text style={[a.font_bold]}>did={did}</Text>. Add it in your DNS
providers control panel.
</Trans>
</Text>
</FaqItem>
<FaqItem title={l`There are multiple TXT records`}>
<Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
There must be exactly one <Text style={[a.font_bold]}>did=</Text>{' '}
TXT record on the <Text style={[a.font_bold]}>_atproto</Text> host.
If you have more than one for example, one left over from a
previous account delete the extras.
</Trans>
</Text>
</FaqItem>
<FaqItem title={l`The record points to the wrong account`}>
<Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
The DID in your TXT record or well-known file must exactly match
this accounts DID: <Text style={[a.font_bold]}>{did}</Text>. A
record copied from another account wont work.
</Trans>
</Text>
</FaqItem>
<FaqItem title={l`The domain expired or isnt resolving`}>
<Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
If your domain registration lapsed or its nameservers are
misconfigured, the handle cant be verified. Check that your domain
is active with your registrar.
</Trans>
</Text>
</FaqItem>
<FaqItem title={l`The change hasnt propagated yet`}>
<Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
DNS changes can take up to 24 hours to take effect. If you recently
fixed your record, wait a little while and press Refresh again.
</Trans>
</Text>
</FaqItem>
<FaqItem title={l`The well-known file is wrong or missing`}>
<Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
If you verify via a file instead of DNS, your site must serve{' '}
<Text style={[a.font_bold]}>
https://your-domain/.well-known/atproto-did
</Text>{' '}
containing exactly <Text style={[a.font_bold]}>{did}</Text>.
</Trans>
</Text>
</FaqItem>
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans context="english-only-resource">
For a full walkthrough, see the{' '}
<InlineLinkText
label={l`View domain handle tutorial`}
to="https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial"
disableMismatchWarning>
domain handle tutorial
</InlineLinkText>
.
</Trans>
</Text>
</View>
)
}
function FaqItem({
title,
children,
}: {
title: string
children: React.ReactNode
}) {
const t = useTheme()
const [expanded, setExpanded] = useState(false)
return (
<View
style={[
a.border,
a.rounded_sm,
a.overflow_hidden,
t.atoms.border_contrast_low,
]}>
<Button
label={title}
onPress={() => setExpanded(prev => !prev)}
style={[a.flex_row, a.align_center, a.gap_sm, a.p_md]}
hoverStyle={[t.atoms.bg_contrast_25]}>
<Text style={[a.flex_1, a.text_md, a.font_semi_bold]}>{title}</Text>
<ButtonIcon icon={expanded ? ChevronTopIcon : ChevronBottomIcon} />
</Button>
<AccordionAnimation isExpanded={expanded}>
<View style={[a.px_md, a.pb_md]}>{children}</View>
</AccordionAnimation>
</View>
)
}
@@ -0,0 +1,160 @@
import {describe, expect, it} from '@jest/globals'
import {
extractIntendedHandle,
isServiceHandle,
pickDiagnosis,
} from '../diagnostics'
import {type DiagnosisInputs} from '../types'
const DID = 'did:plc:abc123'
const OTHER_DID = 'did:plc:someoneelse'
const HANDLE = 'alice.example.com'
describe('extractIntendedHandle', () => {
it('extracts the handle from an at:// alsoKnownAs entry', () => {
expect(extractIntendedHandle({alsoKnownAs: [`at://${HANDLE}`]})).toBe(
HANDLE,
)
})
it('skips non-at:// entries', () => {
expect(
extractIntendedHandle({
alsoKnownAs: ['https://example.com', `at://${HANDLE}`],
}),
).toBe(HANDLE)
})
it('returns undefined when alsoKnownAs is missing or empty', () => {
expect(extractIntendedHandle({})).toBeUndefined()
expect(extractIntendedHandle({alsoKnownAs: []})).toBeUndefined()
expect(extractIntendedHandle(undefined)).toBeUndefined()
expect(extractIntendedHandle(null)).toBeUndefined()
expect(extractIntendedHandle('not an object')).toBeUndefined()
})
it('rejects entries that do not look like a handle', () => {
expect(
extractIntendedHandle({alsoKnownAs: ['at://nodots']}),
).toBeUndefined()
})
it('ignores non-string entries', () => {
expect(
extractIntendedHandle({alsoKnownAs: [42, null, `at://${HANDLE}`]}),
).toBe(HANDLE)
})
})
describe('isServiceHandle', () => {
it('matches handles under an available user domain', () => {
expect(isServiceHandle('alice.bsky.social', ['.bsky.social'])).toBe(true)
})
it('handles domains without a leading dot', () => {
expect(isServiceHandle('alice.bsky.social', ['bsky.social'])).toBe(true)
})
it('does not match custom domains', () => {
expect(isServiceHandle(HANDLE, ['.bsky.social'])).toBe(false)
})
it('does not match the bare domain itself', () => {
expect(isServiceHandle('bsky.social', ['bsky.social'])).toBe(false)
})
it('returns false with no domains', () => {
expect(isServiceHandle(HANDLE, [])).toBe(false)
})
})
describe('pickDiagnosis', () => {
const base: DiagnosisInputs = {
expectedDid: DID,
didDoc: {status: 'ok', intendedHandle: HANDLE},
isServiceHandle: false,
}
it('reports network-unavailable when the DID doc fetch failed on network', () => {
expect(pickDiagnosis({...base, didDoc: {status: 'network-error'}})).toEqual(
{type: 'network-unavailable'},
)
})
it('reports inconclusive when the DID doc fetch failed otherwise', () => {
expect(pickDiagnosis({...base, didDoc: {status: 'error'}})).toEqual({
type: 'inconclusive',
})
})
it('reports no-aka-handle when the DID doc has no handle', () => {
expect(
pickDiagnosis({
...base,
didDoc: {status: 'ok', intendedHandle: undefined},
}),
).toEqual({type: 'no-aka-handle'})
})
it('reports resolves-correctly when resolution matches the expected DID', () => {
expect(
pickDiagnosis({...base, resolution: {status: 'resolved', did: DID}}),
).toEqual({type: 'resolves-correctly', handle: HANDLE})
})
it('reports wrong-did when resolution returns another DID', () => {
expect(
pickDiagnosis({
...base,
resolution: {status: 'resolved', did: OTHER_DID},
}),
).toEqual({type: 'wrong-did', handle: HANDLE, found: OTHER_DID})
})
it('a correct resolution beats the service handle check', () => {
expect(
pickDiagnosis({
...base,
isServiceHandle: true,
resolution: {status: 'resolved', did: DID},
}),
).toEqual({type: 'resolves-correctly', handle: HANDLE})
})
it('reports service-handle-issue over not-resolving for service handles', () => {
expect(
pickDiagnosis({
...base,
isServiceHandle: true,
resolution: {status: 'not-resolving'},
}),
).toEqual({type: 'service-handle-issue', handle: HANDLE})
})
it('reports not-resolving when the server cannot resolve the handle', () => {
expect(
pickDiagnosis({...base, resolution: {status: 'not-resolving'}}),
).toEqual({type: 'not-resolving', handle: HANDLE})
})
it('reports network-unavailable when resolution failed on network', () => {
expect(
pickDiagnosis({...base, resolution: {status: 'network-error'}}),
).toEqual({type: 'network-unavailable'})
})
it('falls through to inconclusive on unexpected resolution errors', () => {
expect(pickDiagnosis({...base, resolution: {status: 'error'}})).toEqual({
type: 'inconclusive',
handle: HANDLE,
})
})
it('falls through to inconclusive when resolution never ran', () => {
expect(pickDiagnosis({...base})).toEqual({
type: 'inconclusive',
handle: HANDLE,
})
})
})
+85
View File
@@ -0,0 +1,85 @@
import {
type DiagnosisInputs,
type IdentityDiagnosis,
} from '#/features/invalidHandle/types'
/**
* Recovers the handle an account is supposed to have from its DID document's
* `alsoKnownAs` entries. This is the only way to learn the intended handle
* when the AppView has already replaced it with `handle.invalid`.
*/
export function extractIntendedHandle(didDoc: unknown): string | undefined {
if (!didDoc || typeof didDoc !== 'object') return undefined
const aka = (didDoc as {alsoKnownAs?: unknown}).alsoKnownAs
if (!Array.isArray(aka)) return undefined
for (const entry of aka) {
if (typeof entry === 'string' && entry.startsWith('at://')) {
const handle = entry.slice('at://'.length)
if (handle.includes('.')) {
return handle
}
}
}
return undefined
}
/**
* Whether the handle is under a domain provided by the user's hosting service
* (e.g. `.bsky.social`). For these, resolution is handled by the service
* itself, so DNS troubleshooting advice does not apply.
*/
export function isServiceHandle(
handle: string,
availableUserDomains: string[],
): boolean {
return availableUserDomains.some(domain => {
const suffix = domain.startsWith('.') ? domain : `.${domain}`
return handle.endsWith(suffix)
})
}
/**
* Combines the individual check results into a single diagnosis. Priority:
* a successful resolution (correct or wrong DID) is the strongest signal,
* then service-provided handles (server-side issue), then resolution
* failures, then failures of the checks themselves.
*/
export function pickDiagnosis({
expectedDid,
didDoc,
isServiceHandle: isService,
resolution,
}: DiagnosisInputs): IdentityDiagnosis {
if (didDoc.status === 'network-error') {
return {type: 'network-unavailable'}
}
if (didDoc.status === 'error') {
return {type: 'inconclusive'}
}
const handle = didDoc.intendedHandle
if (!handle) {
return {type: 'no-aka-handle'}
}
if (resolution?.status === 'resolved') {
if (resolution.did === expectedDid) {
return {type: 'resolves-correctly', handle}
}
return {type: 'wrong-did', handle, found: resolution.did}
}
if (isService) {
return {type: 'service-handle-issue', handle}
}
if (resolution?.status === 'not-resolving') {
return {type: 'not-resolving', handle}
}
if (resolution?.status === 'network-error') {
return {type: 'network-unavailable'}
}
return {type: 'inconclusive', handle}
}
+24
View File
@@ -0,0 +1,24 @@
import {IS_DEV} from '#/env'
import {account} from '#/storage'
/*
* Short snooze in dev so the auto-open behavior can be exercised without
* waiting a day.
*/
const SNOOZE_MS = IS_DEV ? 10_000 : 24 * 60 * 60 * 1000
/**
* Snoozes the invalid handle dialog for this account. Dismissing the dialog
* counts as snoozing; the profile header pill bypasses the snooze entirely.
*/
export function snooze(did: string) {
account.set([did, 'invalidHandleDialogSnoozedAt'], new Date().toISOString())
}
export function isSnoozed(did: string): boolean {
const snoozedAt = account.get([did, 'invalidHandleDialogSnoozedAt'])
if (!snoozedAt) return false
const ts = new Date(snoozedAt).getTime()
if (Number.isNaN(ts)) return false
return Date.now() - ts < SNOOZE_MS
}
+100
View File
@@ -0,0 +1,100 @@
/**
* The likely cause of an invalid handle, as determined by server-side
* resolution checks. See `README.md` and `diagnostics.ts` for how each case is
* derived.
*/
export type IdentityDiagnosis =
| {
/** Offline or the PDS could not be reached, so no checks could run. */
type: 'network-unavailable'
}
| {
/** The DID document does not declare an `at://` handle at all. */
type: 'no-aka-handle'
}
| {
/**
* The server now resolves the intended handle back to this account, so
* the handle is likely fixed and just needs a refresh to propagate.
*/
type: 'resolves-correctly'
handle: string
}
| {
/** The intended handle resolves, but to a different account's DID. */
type: 'wrong-did'
handle: string
found: string
}
| {
/**
* The server cannot resolve the intended handle at all: missing DNS TXT
* record, multiple TXT records, expired domain, missing well-known
* file, etc. The client cannot distinguish these, so the FAQ covers
* the possible causes.
*/
type: 'not-resolving'
handle: string
}
| {
/**
* The handle is under a domain provided by the user's hosting service
* (e.g. `.bsky.social`), so resolution is the server's responsibility
* and DNS advice does not apply.
*/
type: 'service-handle-issue'
handle: string
}
| {
/** Checks ran but did not produce a definite answer. */
type: 'inconclusive'
handle?: string
}
/**
* Result of fetching the account's DID document via
* `com.atproto.repo.describeRepo`.
*/
export type DidDocCheck =
| {status: 'ok'; intendedHandle: string | undefined}
| {status: 'network-error'}
| {status: 'error'}
/**
* Result of asking the server to resolve the intended handle via
* `com.atproto.identity.resolveHandle`.
*/
export type ResolutionCheck =
| {status: 'resolved'; did: string}
| {status: 'not-resolving'}
| {status: 'network-error'}
| {status: 'error'}
export type DiagnosisInputs = {
/** The current account's DID, which the handle should resolve to. */
expectedDid: string
didDoc: DidDocCheck
/**
* Whether the intended handle is under one of the PDS's
* `availableUserDomains`.
*/
isServiceHandle: boolean
/** Undefined when no intended handle was found to resolve. */
resolution?: ResolutionCheck
}
export type DiagnosticsReport = {
/**
* The handle this account is supposed to have, recovered from the DID
* document's `alsoKnownAs`. Undefined if it could not be determined.
*/
intendedHandle?: string
diagnosis: IdentityDiagnosis
/** Raw check results, retained for the dev-mode debug dump. */
raw: {
didDoc?: unknown
handleIsCorrect?: boolean
resolvedDid?: string
resolveError?: string
}
}
@@ -0,0 +1,94 @@
import {ComAtprotoIdentityResolveHandle} from '@atproto/api'
import {useQuery} from '@tanstack/react-query'
import {isNetworkError} from '#/lib/strings/errors'
import {useServiceQuery} from '#/state/queries/service'
import {createQueryKey} from '#/state/queries/util'
import {useAgent, useSession} from '#/state/session'
import {
extractIntendedHandle,
isServiceHandle,
pickDiagnosis,
} from '#/features/invalidHandle/diagnostics'
import {
type DiagnosticsReport,
type DidDocCheck,
type ResolutionCheck,
} from '#/features/invalidHandle/types'
export const createIdentityDiagnosticsQueryKey = (args: {did: string}) =>
createQueryKey('invalidHandleDiagnostics', args)
/**
* Runs server-side resolution checks to determine the likely cause of the
* current account's handle being `handle.invalid`. Each step is individually
* error-handled so the query always returns a report and never throws.
*/
export function useIdentityDiagnosticsQuery({enabled}: {enabled: boolean}) {
const agent = useAgent()
const {currentAccount} = useSession()
const {data: serviceInfo} = useServiceQuery(agent.serviceUrl.toString())
const did = currentAccount?.did ?? ''
const availableUserDomains = serviceInfo?.availableUserDomains
return useQuery({
queryKey: createIdentityDiagnosticsQueryKey({did}),
enabled: enabled && !!did,
retry: false,
staleTime: 0,
gcTime: 0,
queryFn: async (): Promise<DiagnosticsReport> => {
const raw: DiagnosticsReport['raw'] = {}
let didDoc: DidDocCheck
try {
const res = await agent.com.atproto.repo.describeRepo({repo: did})
raw.didDoc = res.data.didDoc
raw.handleIsCorrect = res.data.handleIsCorrect
didDoc = {
status: 'ok',
intendedHandle: extractIntendedHandle(res.data.didDoc),
}
} catch (e) {
didDoc = {status: isNetworkError(e) ? 'network-error' : 'error'}
}
const intendedHandle =
didDoc.status === 'ok' ? didDoc.intendedHandle : undefined
let resolution: ResolutionCheck | undefined
if (intendedHandle) {
try {
const res = await agent.resolveHandle({handle: intendedHandle})
raw.resolvedDid = res.data.did
resolution = {status: 'resolved', did: res.data.did}
} catch (e) {
raw.resolveError = e instanceof Error ? e.message : String(e)
if (
e instanceof ComAtprotoIdentityResolveHandle.HandleNotFoundError ||
/unable to resolve handle/i.test(String(e))
) {
resolution = {status: 'not-resolving'}
} else if (isNetworkError(e)) {
resolution = {status: 'network-error'}
} else {
resolution = {status: 'error'}
}
}
}
return {
intendedHandle,
diagnosis: pickDiagnosis({
expectedDid: did,
didDoc,
isServiceHandle: intendedHandle
? isServiceHandle(intendedHandle, availableUserDomains ?? [])
: false,
resolution,
}),
raw,
}
},
})
}
+56 -33
View File
@@ -1,12 +1,13 @@
import {View} from 'react-native'
import {type AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {isInvalidHandle, sanitizeHandle} from '#/lib/strings/handles'
import {type Shadow} from '#/state/cache/types'
import {useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {Button} from '#/components/Button'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {NewskieDialog} from '#/components/NewskieDialog'
import {Text} from '#/components/Typography'
import {IS_IOS, IS_NATIVE} from '#/env'
@@ -19,8 +20,11 @@ export function ProfileHeaderHandle({
disableTaps?: boolean
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const {currentAccount} = useSession()
const {invalidHandleDialogControl} = useGlobalDialogsControlContext()
const invalidHandle = isInvalidHandle(profile.handle)
const isOwnProfile = profile.did === currentAccount?.did
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
return (
<View
@@ -34,35 +38,54 @@ export function ProfileHeaderHandle({
</Text>
</View>
) : undefined}
<Text
emoji
numberOfLines={1}
style={[
invalidHandle
? [
a.border,
a.text_xs,
a.px_sm,
a.py_xs,
a.rounded_xs,
{borderColor: t.palette.contrast_200},
]
: [a.text_md, a.leading_snug, t.atoms.text_contrast_medium],
web({
wordBreak: 'break-all',
direction: 'ltr',
unicodeBidi: 'isolate',
}),
]}>
{invalidHandle
? _(msg`⚠Invalid Handle`)
: sanitizeHandle(
profile.handle,
'@',
// forceLTR handled by CSS above on web
IS_NATIVE,
)}
</Text>
{invalidHandle && isOwnProfile && !disableTaps ? (
<Button
label={l`Learn why your handle is invalid`}
accessibilityHint={l`Opens dialog with details and troubleshooting steps`}
onPress={() => invalidHandleDialogControl.open()}
style={[
a.border,
a.px_sm,
a.py_xs,
a.rounded_xs,
{borderColor: t.palette.contrast_200},
]}
hoverStyle={[t.atoms.bg_contrast_25]}>
<Text style={[a.text_xs]}>
<Trans>Invalid Handle</Trans>
</Text>
</Button>
) : (
<Text
emoji
numberOfLines={1}
style={[
invalidHandle
? [
a.border,
a.text_xs,
a.px_sm,
a.py_xs,
a.rounded_xs,
{borderColor: t.palette.contrast_200},
]
: [a.text_md, a.leading_snug, t.atoms.text_contrast_medium],
web({
wordBreak: 'break-all',
direction: 'ltr',
unicodeBidi: 'isolate',
}),
]}>
{invalidHandle
? l`⚠Invalid Handle`
: sanitizeHandle(
profile.handle,
'@',
// forceLTR handled by CSS above on web
IS_NATIVE,
)}
</Text>
)}
</View>
)
}
+7
View File
@@ -87,6 +87,13 @@ export type Account = {
lastSelectedHomeFeed?: string
/**
* The ISO date string of when the invalid handle dialog was last dismissed
* for this account. While within the snooze window, the dialog won't
* auto-open again (but can still be opened from the profile header).
*/
invalidHandleDialogSnoozedAt?: string
/**
* Recently selected GIFs in the GIF picker. Most recent first, capped at 20.
*/
+2
View File
@@ -43,6 +43,7 @@ import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
import {PassiveAnalytics} from '#/analytics/PassiveAnalytics'
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS} from '#/env'
import {InvalidHandleDialog} from '#/features/invalidHandle/InvalidHandleDialog'
import {RoutesContainer, TabsNavigator} from '#/Navigation'
import {BottomSheetOutlet} from '../../../modules/bottom-sheet'
import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
@@ -115,6 +116,7 @@ function ShellInner() {
<LinkWarningDialog />
<Lightbox />
<NuxDialogs />
<InvalidHandleDialog />
<GlobalReportDialog />
{/* Until policy update has been completed by the user, don't render anything that is portaled */}
+2
View File
@@ -33,6 +33,7 @@ import {useAgeAssurance} from '#/ageAssurance'
import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
import {PassiveAnalytics} from '#/analytics/PassiveAnalytics'
import {InvalidHandleDialog} from '#/features/invalidHandle/InvalidHandleDialog'
import {FlatNavigator, RoutesContainer} from '#/Navigation'
import {Composer} from './Composer'
import {DrawerContent} from './Drawer'
@@ -71,6 +72,7 @@ function ShellInner() {
<LinkWarningDialog />
<Lightbox />
<NuxDialogs />
<InvalidHandleDialog />
<GlobalReportDialog />
{welcomeModalControl.isOpen && (