From 780a1bac5c8100de010a6856875d06f8b9f91bab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 19:40:29 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01LSXAgZis8iP5KmcUcGXCKw --- src/components/dialogs/Context.tsx | 4 + .../invalidHandle/InvalidHandleDialog.tsx | 502 ++++++++++++++++++ .../__tests__/diagnostics.test.ts | 160 ++++++ src/features/invalidHandle/diagnostics.ts | 85 +++ src/features/invalidHandle/snoozing.ts | 24 + src/features/invalidHandle/types.ts | 100 ++++ .../invalidHandle/useDiagnosticsQuery.ts | 94 ++++ src/screens/Profile/Header/Handle.tsx | 89 ++-- src/storage/schema.ts | 7 + src/view/shell/index.tsx | 2 + src/view/shell/index.web.tsx | 2 + 11 files changed, 1036 insertions(+), 33 deletions(-) create mode 100644 src/features/invalidHandle/InvalidHandleDialog.tsx create mode 100644 src/features/invalidHandle/__tests__/diagnostics.test.ts create mode 100644 src/features/invalidHandle/diagnostics.ts create mode 100644 src/features/invalidHandle/snoozing.ts create mode 100644 src/features/invalidHandle/types.ts create mode 100644 src/features/invalidHandle/useDiagnosticsQuery.ts diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx index f1e4705df5..86ec42f8eb 100644 --- a/src/components/dialogs/Context.tsx +++ b/src/components/dialogs/Context.tsx @@ -17,6 +17,7 @@ export type StatefulControl = { type ControlsContext = { mutedWordsDialogControl: Control signinDialogControl: Control + invalidHandleDialogControl: Control inAppBrowserConsentControl: StatefulControl emailDialogControl: StatefulControl 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() const emailDialogControl = useStatefulDialogControl() 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, diff --git a/src/features/invalidHandle/InvalidHandleDialog.tsx b/src/features/invalidHandle/InvalidHandleDialog.tsx new file mode 100644 index 0000000000..231ffa9ff3 --- /dev/null +++ b/src/features/invalidHandle/InvalidHandleDialog.tsx @@ -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 ( + <> + { + if (did) snooze(did) + }}> + + changeHandleControl.open()} + /> + + + + ) +} + +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 ( + + + + + We couldn’t verify your handle + + {intendedHandle ? ( + + + Your account points to{' '} + @{intendedHandle}, + but we couldn’t confirm that this handle belongs to you. Until + it’s fixed, your handle will appear as invalid. + + + ) : ( + + + We couldn’t confirm that your handle belongs to you. Until it’s + fixed, your handle will appear as invalid. + + + )} + + + {isDiagnosing ? ( + + + + ) : report ? ( + + ) : null} + + {refreshFixedHandle === false && !isRefreshing && ( + + + 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. + + + )} + + {report && } + + + + + + + + + + + {devMode && report && ( + + + Debug + + + + {JSON.stringify(report.raw, null, 2)} + + + )} + + + + ) +} + +function DiagnosisMessage({diagnosis}: {diagnosis: IdentityDiagnosis}) { + switch (diagnosis.type) { + case 'resolves-correctly': + return ( + + + Good news – your handle now appears to be set up correctly. Press + Refresh below to re-verify it. + + + ) + case 'wrong-did': + return ( + + + @{diagnosis.handle} 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. + + + ) + case 'not-resolving': + return ( + + + @{diagnosis.handle} isn’t + resolving to your account. This usually means a problem with the + domain’s DNS record – see the likely causes below. + + + ) + case 'service-handle-issue': + return ( + + + 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. + + + ) + case 'no-aka-handle': + return ( + + + Your account doesn’t declare a handle. Set a new one using the + “Change my handle” button below. + + + ) + case 'network-unavailable': + return ( + + + We couldn’t run checks on your handle – you appear to be offline. + + + ) + case 'inconclusive': + return ( + + + We couldn’t determine the exact cause from this device. See the + likely causes below. + + + ) + } +} + +/** + * 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 ( + + + Your DNS TXT record value should be: + + + + did={currentAccount.did} + + + + + ) +} + +function LikelyCauses() { + const t = useTheme() + const {t: l} = useLingui() + const {currentAccount} = useSession() + const did = currentAccount?.did ?? '' + + return ( + + + Likely causes + + + + + For a custom domain handle, your domain must have a TXT record with + host _atproto and value{' '} + did={did}. Add it in your DNS + provider’s control panel. + + + + + + + There must be exactly one did={' '} + TXT record on the _atproto host. + If you have more than one – for example, one left over from a + previous account – delete the extras. + + + + + + + The DID in your TXT record or well-known file must exactly match + this account’s DID: {did}. A + record copied from another account won’t work. + + + + + + + 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. + + + + + + + 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. + + + + + + + If you verify via a file instead of DNS, your site must serve{' '} + + https://your-domain/.well-known/atproto-did + {' '} + containing exactly {did}. + + + + + + For a full walkthrough, see the{' '} + + domain handle tutorial + + . + + + + ) +} + +function FaqItem({ + title, + children, +}: { + title: string + children: React.ReactNode +}) { + const t = useTheme() + const [expanded, setExpanded] = useState(false) + + return ( + + + + {children} + + + ) +} diff --git a/src/features/invalidHandle/__tests__/diagnostics.test.ts b/src/features/invalidHandle/__tests__/diagnostics.test.ts new file mode 100644 index 0000000000..0b4639b61c --- /dev/null +++ b/src/features/invalidHandle/__tests__/diagnostics.test.ts @@ -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, + }) + }) +}) diff --git a/src/features/invalidHandle/diagnostics.ts b/src/features/invalidHandle/diagnostics.ts new file mode 100644 index 0000000000..9f622d2470 --- /dev/null +++ b/src/features/invalidHandle/diagnostics.ts @@ -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} +} diff --git a/src/features/invalidHandle/snoozing.ts b/src/features/invalidHandle/snoozing.ts new file mode 100644 index 0000000000..e5c1cc47bf --- /dev/null +++ b/src/features/invalidHandle/snoozing.ts @@ -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 +} diff --git a/src/features/invalidHandle/types.ts b/src/features/invalidHandle/types.ts new file mode 100644 index 0000000000..93dc674c4d --- /dev/null +++ b/src/features/invalidHandle/types.ts @@ -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 + } +} diff --git a/src/features/invalidHandle/useDiagnosticsQuery.ts b/src/features/invalidHandle/useDiagnosticsQuery.ts new file mode 100644 index 0000000000..bb5bb04129 --- /dev/null +++ b/src/features/invalidHandle/useDiagnosticsQuery.ts @@ -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 => { + 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, + } + }, + }) +} diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx index f659b923cd..7d736dd4f1 100644 --- a/src/screens/Profile/Header/Handle.tsx +++ b/src/screens/Profile/Header/Handle.tsx @@ -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 ( ) : undefined} - - {invalidHandle - ? _(msg`⚠Invalid Handle`) - : sanitizeHandle( - profile.handle, - '@', - // forceLTR handled by CSS above on web - IS_NATIVE, - )} - + {invalidHandle && isOwnProfile && !disableTaps ? ( + + ) : ( + + {invalidHandle + ? l`⚠Invalid Handle` + : sanitizeHandle( + profile.handle, + '@', + // forceLTR handled by CSS above on web + IS_NATIVE, + )} + + )} ) } diff --git a/src/storage/schema.ts b/src/storage/schema.ts index add2e9a0a8..680dac6f2f 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -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. */ diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 126ce485b6..1137b82053 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -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() { + {/* Until policy update has been completed by the user, don't render anything that is portaled */} diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 6a68530f05..a0ffeefae7 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -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() { + {welcomeModalControl.isOpen && (