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:
@@ -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 couldn’t 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 couldn’t confirm that this handle belongs to you. Until
|
||||
it’s fixed, your handle will appear as invalid.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||
<Trans>
|
||||
We couldn’t confirm that your handle belongs to you. Until it’s
|
||||
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 account’s DID, shown
|
||||
below.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
)
|
||||
case 'not-resolving':
|
||||
return (
|
||||
<Admonition type="warning">
|
||||
<Trans>
|
||||
<Text style={[a.font_bold]}>@{diagnosis.handle}</Text> isn’t
|
||||
resolving to your account. This usually means a problem with the
|
||||
domain’s 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 doesn’t resolve.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
)
|
||||
case 'no-aka-handle':
|
||||
return (
|
||||
<Admonition type="error">
|
||||
<Trans>
|
||||
Your account doesn’t 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 couldn’t run checks on your handle – you appear to be offline.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
)
|
||||
case 'inconclusive':
|
||||
return (
|
||||
<Admonition type="info">
|
||||
<Trans>
|
||||
We couldn’t 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
|
||||
provider’s 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 account’s DID: <Text style={[a.font_bold]}>{did}</Text>. A
|
||||
record copied from another account won’t work.
|
||||
</Trans>
|
||||
</Text>
|
||||
</FaqItem>
|
||||
<FaqItem title={l`The domain expired or isn’t resolving`}>
|
||||
<Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
|
||||
<Trans>
|
||||
If your domain registration lapsed or its nameservers are
|
||||
misconfigured, the handle can’t be verified. Check that your domain
|
||||
is active with your registrar.
|
||||
</Trans>
|
||||
</Text>
|
||||
</FaqItem>
|
||||
<FaqItem title={l`The change hasn’t 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user