From 934d2e2170878974b5c08e2c68b8d12ff1d0881a Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 17 Jul 2026 19:21:45 +0300 Subject: [PATCH] restore compile-time error matching with isXrpcErrorOf The migration replaced typed error classes (e instanceof SomeNsid.SomeError) with getErrorName(e) === 'X' string comparisons, where a typo'd or undeclared name compiles silently. isXrpcErrorOf (method, e, name) constrains name to InferMethodError - the errors tuple the generated lexicon schema declares - restoring the old compile-time guarantee (verified: a typo'd name fails typecheck). 47 comparison sites across 16 files migrated. Deliberately left on getErrorName, with comments: - LoginForm AuthFactorTokenRequired: a LexAuthFactorError from PasswordSession.login, not an XrpcError. - AppealForm AlreadyAppealed: createReport declares NO errors in its lexicon; the server sends it anyway (undeclared behavior worth raising upstream). - PostFeedErrorMessage Block detection: generic over feed descriptors, source method genuinely ambiguous. - isErrorMaybeAppPasswordPermissions: generic InvalidToken + message. Co-Authored-By: Claude Fable 5 --- .../ageAssurance/AgeAssuranceInitDialog.tsx | 10 +-- .../contacts/screens/GetContacts.tsx | 10 ++- .../contacts/screens/PhoneInput.tsx | 18 ++++- .../contacts/screens/VerifyNumber.tsx | 30 +++++++-- src/components/dms/LeaveConvoPrompt.tsx | 11 ++- src/components/dms/dialogs/NewChatDialog.tsx | 67 +++++++++++++++---- .../intents/GroupChatJoinDialog.tsx | 38 ++++++++--- src/components/moderation/AppealForm.tsx | 1 + src/components/moderation/BlockDialog.tsx | 24 +++++-- src/features/liveNow/index.tsx | 5 +- src/lib/strings/__tests__/errors.test.ts | 40 +++++++++++ src/lib/xrpc-error.ts | 29 +++++++- src/screens/Login/LoginForm.tsx | 1 + .../Messages/ConversationSettings/index.tsx | 6 +- src/screens/Messages/JoinRequests.tsx | 44 ++++++++++-- .../Messages/components/ChatLocked.tsx | 7 +- .../components/OutgoingRequestListItem.tsx | 12 +++- src/screens/Signup/state.ts | 12 +++- src/view/com/composer/Composer.tsx | 4 +- src/view/com/composer/drafts/state/queries.ts | 6 +- src/view/com/posts/PostFeedErrorMessage.tsx | 5 ++ 21 files changed, 311 insertions(+), 69 deletions(-) diff --git a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx index 445c3e3eed..19597186b6 100644 --- a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx +++ b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx @@ -13,7 +13,7 @@ import { import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useTLDs} from '#/lib/hooks/useTLDs' import {isEmailMaybeInvalid} from '#/lib/strings/email' -import {getErrorName, isXrpcError} from '#/lib/xrpc-error' +import {isXrpcError, isXrpcErrorOf} from '#/lib/xrpc-error' import {type AppLanguage} from '#/locale/languages' import {useLanguagePrefs} from '#/state/preferences' import {useSession} from '#/state/session' @@ -33,6 +33,7 @@ import {Text} from '#/components/Typography' import {useAgeAssurance} from '#/ageAssurance' import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance' import {useAnalytics} from '#/analytics' +import {app} from '#/lexicons' export {useDialogControl} from '#/components/Dialog/context' @@ -140,13 +141,14 @@ function Inner() { ) if (isXrpcError(e)) { - const errorName = getErrorName(e) - if (errorName === 'InvalidEmail') { + if (isXrpcErrorOf(app.bsky.ageassurance.begin, e, 'InvalidEmail')) { error = _( msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`, ) ax.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'}) - } else if (errorName === 'DidTooLong') { + } else if ( + isXrpcErrorOf(app.bsky.ageassurance.begin, e, 'DidTooLong') + ) { error = ( <> diff --git a/src/components/contacts/screens/GetContacts.tsx b/src/components/contacts/screens/GetContacts.tsx index 64334b5485..58e4437c22 100644 --- a/src/components/contacts/screens/GetContacts.tsx +++ b/src/components/contacts/screens/GetContacts.tsx @@ -13,7 +13,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query' import {uploadBlob} from '#/lib/api' import {cleanError, isNetworkError} from '#/lib/strings/errors' -import {getErrorName} from '#/lib/xrpc-error' +import {isXrpcErrorOf} from '#/lib/xrpc-error' import {logger} from '#/logger' import {findContactsStatusQueryKey} from '#/state/queries/find-contacts' import {useAppviewClient, usePdsClient} from '#/state/session' @@ -149,14 +149,18 @@ export function GetContacts({ ), {type: 'error'}, ) - } else if (getErrorName(err) === 'TooManyContacts') { + } else if ( + isXrpcErrorOf(app.bsky.contact.importContacts, err, 'TooManyContacts') + ) { Toast.show( _( msg`Too many contacts - you've exceeded the number of contacts you can import to find your friends`, ), {type: 'error'}, ) - } else if (getErrorName(err) === 'InvalidToken') { + } else if ( + isXrpcErrorOf(app.bsky.contact.importContacts, err, 'InvalidToken') + ) { Toast.show( _( msg`Could not upload contacts. You need to re-verify your phone number to proceed`, diff --git a/src/components/contacts/screens/PhoneInput.tsx b/src/components/contacts/screens/PhoneInput.tsx index 4655817f6c..903c098560 100644 --- a/src/components/contacts/screens/PhoneInput.tsx +++ b/src/components/contacts/screens/PhoneInput.tsx @@ -13,7 +13,7 @@ import { getDefaultCountry, } from '#/lib/international-telephone-codes' import {cleanError, isNetworkError} from '#/lib/strings/errors' -import {getErrorName} from '#/lib/xrpc-error' +import {isXrpcErrorOf} from '#/lib/xrpc-error' import {logger} from '#/logger' import {useAppviewClient} from '#/state/session' import {OnboardingPosition} from '#/screens/Onboarding/Layout' @@ -103,9 +103,21 @@ export function PhoneInput({ msg`A network error occurred. Please check your internet connection`, ), ) - } else if (getErrorName(err) === 'RateLimitExceeded') { + } else if ( + isXrpcErrorOf( + app.bsky.contact.startPhoneVerification, + err, + 'RateLimitExceeded', + ) + ) { setError(_(msg`Rate limit exceeded. Please try again later.`)) - } else if (getErrorName(err) === 'InvalidPhone') { + } else if ( + isXrpcErrorOf( + app.bsky.contact.startPhoneVerification, + err, + 'InvalidPhone', + ) + ) { setError( _( msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`, diff --git a/src/components/contacts/screens/VerifyNumber.tsx b/src/components/contacts/screens/VerifyNumber.tsx index 0596808c53..4720822e6c 100644 --- a/src/components/contacts/screens/VerifyNumber.tsx +++ b/src/components/contacts/screens/VerifyNumber.tsx @@ -7,7 +7,7 @@ import {useMutation} from '@tanstack/react-query' import {clamp} from '#/lib/numbers' import {cleanError, isNetworkError} from '#/lib/strings/errors' -import {getErrorName} from '#/lib/xrpc-error' +import {isXrpcErrorOf} from '#/lib/xrpc-error' import {logger} from '#/logger' import {useAppviewClient} from '#/state/session' import {OnboardingPosition} from '#/screens/Onboarding/Layout' @@ -100,13 +100,17 @@ export function VerifyNumber({ msg`A network error occurred. Please check your internet connection.`, ), }) - } else if (getErrorName(err) === 'InvalidCode') { + } else if ( + isXrpcErrorOf(app.bsky.contact.verifyPhone, err, 'InvalidCode') + ) { setError({ retryable: true, isResendError: true, message: _(msg`This code is invalid. Resend to get a new code.`), }) - } else if (getErrorName(err) === 'InvalidPhone') { + } else if ( + isXrpcErrorOf(app.bsky.contact.verifyPhone, err, 'InvalidPhone') + ) { setError({ retryable: false, isResendError: false, @@ -114,7 +118,9 @@ export function VerifyNumber({ msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`, ), }) - } else if (getErrorName(err) === 'RateLimitExceeded') { + } else if ( + isXrpcErrorOf(app.bsky.contact.verifyPhone, err, 'RateLimitExceeded') + ) { setError({ retryable: true, isResendError: false, @@ -156,7 +162,13 @@ export function VerifyNumber({ msg`A network error occurred. Please check your internet connection.`, ), }) - } else if (getErrorName(err) === 'InvalidPhone') { + } else if ( + isXrpcErrorOf( + app.bsky.contact.startPhoneVerification, + err, + 'InvalidPhone', + ) + ) { setError({ retryable: false, isResendError: true, @@ -164,7 +176,13 @@ export function VerifyNumber({ msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`, ), }) - } else if (getErrorName(err) === 'RateLimitExceeded') { + } else if ( + isXrpcErrorOf( + app.bsky.contact.startPhoneVerification, + err, + 'RateLimitExceeded', + ) + ) { setError({ retryable: true, isResendError: true, diff --git a/src/components/dms/LeaveConvoPrompt.tsx b/src/components/dms/LeaveConvoPrompt.tsx index 761c85732e..88b5080ea7 100644 --- a/src/components/dms/LeaveConvoPrompt.tsx +++ b/src/components/dms/LeaveConvoPrompt.tsx @@ -3,12 +3,13 @@ import {StackActions, useNavigation} from '@react-navigation/native' import {type NavigationProp} from '#/lib/routes/types' import {isNetworkError} from '#/lib/strings/errors' -import {getErrorName} from '#/lib/xrpc-error' +import {isXrpcErrorOf} from '#/lib/xrpc-error' import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' import {type DialogOuterProps} from '#/components/Dialog' import * as Prompt from '#/components/Prompt' import * as Toast from '#/components/Toast' import {IS_NATIVE} from '#/env' +import {chat} from '#/lexicons' export function LeaveConvoPrompt({ control, @@ -36,9 +37,13 @@ export function LeaveConvoPrompt({ let errorMessage = l`Could not leave chat` if (isNetworkError(error)) { errorMessage = l`A network error occurred. Please check your internet connection.` - } else if (getErrorName(error) === 'InvalidConvo') { + } else if ( + isXrpcErrorOf(chat.bsky.convo.leaveConvo, error, 'InvalidConvo') + ) { errorMessage = l`Conversation not found.` - } else if (getErrorName(error) === 'OwnerCannotLeave') { + } else if ( + isXrpcErrorOf(chat.bsky.convo.leaveConvo, error, 'OwnerCannotLeave') + ) { errorMessage = l`Owner must lock the group before leaving.` } Toast.show(errorMessage, {type: 'error'}) diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index c0b4e614c8..da88708193 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -3,7 +3,7 @@ import {Trans, useLingui} from '@lingui/react/macro' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {isNetworkError} from '#/lib/strings/errors' -import {getErrorName} from '#/lib/xrpc-error' +import {isXrpcErrorOf} from '#/lib/xrpc-error' import {logger} from '#/logger' import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' @@ -16,6 +16,7 @@ import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow' import {MessagePlus_Stroke2_Corner0_Rounded as NewChatIcon} from '#/components/icons/Message' import * as Toast from '#/components/Toast' import {useAnalytics} from '#/analytics' +import {chat} from '#/lexicons' export function NewChat({ control, @@ -51,15 +52,41 @@ export function NewChat({ let errorMessage = l`An issue occurred starting the chat, please try again.` if (isNetworkError(error)) { errorMessage = l`A network error occurred. Please check your internet connection.` - } else if (getErrorName(error) === 'AccountSuspended') { + } else if ( + isXrpcErrorOf( + chat.bsky.convo.getConvoForMembers, + error, + 'AccountSuspended', + ) + ) { errorMessage = l`Suspended accounts cannot participate in chat.` - } else if (getErrorName(error) === 'BlockedActor') { + } else if ( + isXrpcErrorOf(chat.bsky.convo.getConvoForMembers, error, 'BlockedActor') + ) { errorMessage = l`This user has blocked you and cannot be messaged.` - } else if (getErrorName(error) === 'MessagesDisabled') { + } else if ( + isXrpcErrorOf( + chat.bsky.convo.getConvoForMembers, + error, + 'MessagesDisabled', + ) + ) { errorMessage = l`This user has disabled chat and cannot be messaged.` - } else if (getErrorName(error) === 'NotFollowedBySender') { + } else if ( + isXrpcErrorOf( + chat.bsky.convo.getConvoForMembers, + error, + 'NotFollowedBySender', + ) + ) { errorMessage = l`Chat recipient is not followed by the sender.` - } else if (getErrorName(error) === 'RecipientNotFound') { + } else if ( + isXrpcErrorOf( + chat.bsky.convo.getConvoForMembers, + error, + 'RecipientNotFound', + ) + ) { errorMessage = l`Unable to find the selected recipient.` } Toast.show(errorMessage, { @@ -78,17 +105,33 @@ export function NewChat({ let errorMessage = l`An issue occurred starting the group chat, please try again.` if (isNetworkError(error)) { errorMessage = l`A network error occurred. Please check your internet connection.` - } else if (getErrorName(error) === 'AccountSuspended') { + } else if ( + isXrpcErrorOf(chat.bsky.group.createGroup, error, 'AccountSuspended') + ) { errorMessage = l`Suspended accounts cannot participate in a group chat.` - } else if (getErrorName(error) === 'BlockedActor') { + } else if ( + isXrpcErrorOf(chat.bsky.group.createGroup, error, 'BlockedActor') + ) { errorMessage = l`One of the selected recipients has blocked you and cannot be messaged.` - } else if (getErrorName(error) === 'NewAccountCannotCreateGroup') { + } else if ( + isXrpcErrorOf( + chat.bsky.group.createGroup, + error, + 'NewAccountCannotCreateGroup', + ) + ) { errorMessage = l`You cannot create a group chat yet.` - } else if (getErrorName(error) === 'NotFollowedBySender') { + } else if ( + isXrpcErrorOf(chat.bsky.group.createGroup, error, 'NotFollowedBySender') + ) { errorMessage = l`A selected recipient is not followed by the sender.` - } else if (getErrorName(error) === 'RecipientNotFound') { + } else if ( + isXrpcErrorOf(chat.bsky.group.createGroup, error, 'RecipientNotFound') + ) { errorMessage = l`Unable to find a selected recipient.` - } else if (getErrorName(error) === 'UserForbidsGroups') { + } else if ( + isXrpcErrorOf(chat.bsky.group.createGroup, error, 'UserForbidsGroups') + ) { errorMessage = l`One of the selected recipients does not allow group chats.` } Toast.show(errorMessage, { diff --git a/src/components/intents/GroupChatJoinDialog.tsx b/src/components/intents/GroupChatJoinDialog.tsx index 249d787dd4..b611b1b0e9 100644 --- a/src/components/intents/GroupChatJoinDialog.tsx +++ b/src/components/intents/GroupChatJoinDialog.tsx @@ -11,7 +11,7 @@ import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' import {isNetworkError} from '#/lib/strings/errors' import {sanitizeHandle} from '#/lib/strings/handles' -import {getErrorName} from '#/lib/xrpc-error' +import {isXrpcErrorOf} from '#/lib/xrpc-error' import {logger} from '#/logger' import {useModerationOpts} from '#/state/preferences/moderation-opts' import { @@ -145,15 +145,29 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { let errorMessage = l`Failed to join the group chat. Please try again.` if (isNetworkError(error)) { errorMessage = l`There was a problem with your internet connection, please try again` - } else if (getErrorName(error) === 'ConvoLocked') { + } else if ( + isXrpcErrorOf(chat.bsky.group.requestJoin, error, 'ConvoLocked') + ) { errorMessage = l`This conversation is locked.` - } else if (getErrorName(error) === 'FollowRequired') { + } else if ( + isXrpcErrorOf(chat.bsky.group.requestJoin, error, 'FollowRequired') + ) { errorMessage = l`Only followers can join this group chat.` - } else if (getErrorName(error) === 'InvalidCode') { + } else if ( + isXrpcErrorOf(chat.bsky.group.requestJoin, error, 'InvalidCode') + ) { errorMessage = l`Invalid group chat code.` - } else if (getErrorName(error) === 'LinkDisabled') { + } else if ( + isXrpcErrorOf(chat.bsky.group.requestJoin, error, 'LinkDisabled') + ) { errorMessage = l`This invite link has been disabled.` - } else if (getErrorName(error) === 'MemberLimitReached') { + } else if ( + isXrpcErrorOf( + chat.bsky.group.requestJoin, + error, + 'MemberLimitReached', + ) + ) { errorMessage = l`The member limit has been reached.` const preview = data?.joinLinkPreviews[0] if ( @@ -164,7 +178,9 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { convoId: preview.convo.id, }) } - } else if (getErrorName(error) === 'UserKicked') { + } else if ( + isXrpcErrorOf(chat.bsky.group.requestJoin, error, 'UserKicked') + ) { errorMessage = l`You have been previously removed from this group and can’t join it using this link.` } Toast.show(errorMessage) @@ -185,7 +201,13 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { let errorMessage = l`Failed to rescind your request. Please try again.` if (isNetworkError(error)) { errorMessage = l`There was a problem with your internet connection, please try again` - } else if (getErrorName(error) === 'InvalidJoinRequest') { + } else if ( + isXrpcErrorOf( + chat.bsky.group.withdrawJoinRequest, + error, + 'InvalidJoinRequest', + ) + ) { errorMessage = l`Invalid rescind request.` } Toast.show(errorMessage) diff --git a/src/components/moderation/AppealForm.tsx b/src/components/moderation/AppealForm.tsx index 2036be915e..5097af6e1a 100644 --- a/src/components/moderation/AppealForm.tsx +++ b/src/components/moderation/AppealForm.tsx @@ -66,6 +66,7 @@ export function AppealForm({ ) }, onError: err => { + /* not a declared error of com.atproto.moderation.createReport (which declares none); server sends it anyway */ if (isXrpcError(err) && getErrorName(err) === 'AlreadyAppealed') { setError( _( diff --git a/src/components/moderation/BlockDialog.tsx b/src/components/moderation/BlockDialog.tsx index d00a823171..b4e7a062eb 100644 --- a/src/components/moderation/BlockDialog.tsx +++ b/src/components/moderation/BlockDialog.tsx @@ -4,7 +4,7 @@ import {Trans, useLingui} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {isNetworkError} from '#/lib/strings/errors' -import {getErrorName} from '#/lib/xrpc-error' +import {isXrpcErrorOf} from '#/lib/xrpc-error' import {logger} from '#/logger' import {type Shadow} from '#/state/cache/types' import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' @@ -23,7 +23,7 @@ import {parseConvoView} from '#/components/dms/util' import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' -import {type chat} from '#/lexicons' +import {chat} from '#/lexicons' import {type AnyProfileView} from '#/types/bsky/profile' type Item = chat.bsky.convo.defs.ConvoView @@ -279,9 +279,13 @@ function MutualGroupChat({ let errorMessage = l`Could not leave chat.` if (isNetworkError(error)) { errorMessage = l`A network error occurred. Please check your internet connection.` - } else if (getErrorName(error) === 'InvalidConvo') { + } else if ( + isXrpcErrorOf(chat.bsky.convo.leaveConvo, error, 'InvalidConvo') + ) { errorMessage = l`Chat not found.` - } else if (getErrorName(error) === 'OwnerCannotLeave') { + } else if ( + isXrpcErrorOf(chat.bsky.convo.leaveConvo, error, 'OwnerCannotLeave') + ) { errorMessage = l`Chat owners cannot leave a group chat.` } Toast.show(errorMessage, {type: 'error'}) @@ -303,9 +307,17 @@ function MutualGroupChat({ let errorMessage = l`Could not remove member.` if (isNetworkError(error)) { errorMessage = l`A network error occurred. Please check your internet connection.` - } else if (getErrorName(error) === 'InvalidConvo') { + } else if ( + isXrpcErrorOf(chat.bsky.group.removeMembers, error, 'InvalidConvo') + ) { errorMessage = l`Chat not found.` - } else if (getErrorName(error) === 'InsufficientRole') { + } else if ( + isXrpcErrorOf( + chat.bsky.group.removeMembers, + error, + 'InsufficientRole', + ) + ) { errorMessage = l`You must be a chat owner to remove a member.` } Toast.show(errorMessage, {type: 'error'}) diff --git a/src/features/liveNow/index.tsx b/src/features/liveNow/index.tsx index 449f486340..79b393a8d0 100644 --- a/src/features/liveNow/index.tsx +++ b/src/features/liveNow/index.tsx @@ -16,7 +16,7 @@ import {isAfter, parseISO} from 'date-fns' import {uploadBlob} from '#/lib/api' import {imageToThumb} from '#/lib/api/resolve' import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' -import {getErrorName} from '#/lib/xrpc-error' +import {isXrpcErrorOf} from '#/lib/xrpc-error' import {useAppConfig} from '#/state/appConfig' import { updateProfileShadow, @@ -291,7 +291,8 @@ export function useUpsertLiveStatusMutation( await retry(upsert, { maxRetries: 5, - retryable: e => getErrorName(e) === 'InvalidSwap', + retryable: e => + isXrpcErrorOf(com.atproto.repo.putRecord, e, 'InvalidSwap'), }) return { diff --git a/src/lib/strings/__tests__/errors.test.ts b/src/lib/strings/__tests__/errors.test.ts index ffd413deaa..6bb1f5f347 100644 --- a/src/lib/strings/__tests__/errors.test.ts +++ b/src/lib/strings/__tests__/errors.test.ts @@ -6,7 +6,9 @@ import { getErrorName, getErrorStatus, isXrpcError, + isXrpcErrorOf, } from '#/lib/xrpc-error' +import {chat} from '#/lexicons' import {isErrorMaybeAppPasswordPermissions, isNetworkError} from '../errors' /** @@ -60,6 +62,44 @@ describe('getErrorName', () => { }) }) +describe('isXrpcErrorOf', () => { + it('matches a declared error code for the method', () => { + expect( + isXrpcErrorOf( + chat.bsky.convo.acceptConvo, + lexError(400, 'InvalidConvo'), + 'InvalidConvo', + ), + ).toBe(true) + }) + + it('does not match a different error code or a non-XRPC value', () => { + expect( + isXrpcErrorOf( + chat.bsky.convo.getMessages, + lexError(400, 'InvalidConvo'), + 'InvalidConvo', + ), + ).toBe(true) + expect( + isXrpcErrorOf( + chat.bsky.convo.acceptConvo, + new Error('boom'), + 'InvalidConvo', + ), + ).toBe(false) + }) + + it('constrains the error name to the method declared errors at compile time', () => { + isXrpcErrorOf( + chat.bsky.convo.acceptConvo, + lexError(400, 'InvalidConvo'), + // @ts-expect-error 'Bogus' is not a declared error of acceptConvo + 'Bogus', + ) + }) +}) + describe('getErrorHeader', () => { it('reads a header from the lex Headers object', () => { const e = lexError(429, 'RateLimitExceeded', {'ratelimit-reset': '123'}) diff --git a/src/lib/xrpc-error.ts b/src/lib/xrpc-error.ts index d403ea3600..c5084bed95 100644 --- a/src/lib/xrpc-error.ts +++ b/src/lib/xrpc-error.ts @@ -1,4 +1,12 @@ -import {LexError, XrpcError, XrpcResponseError} from '@atproto/lex' +import { + type InferMethodError, + LexError, + type Main, + type Procedure, + type Query, + XrpcError, + XrpcResponseError, +} from '@atproto/lex' /** * True for an XRPC error from a lex `Client` (`@atproto/lex-client` `XrpcError`, @@ -30,6 +38,25 @@ export function getErrorName(e: unknown): string | undefined { return e instanceof LexError ? e.error : undefined } +/** + * True when `e` is an XRPC error whose lexicon error code is `name`, with + * `name` compile-time constrained to the errors DECLARED by `method`'s + * lexicon. The typed replacement for the old generated error classes + * (`e instanceof SomeNsid.SomeError`): a typo'd or undeclared name is a type + * error instead of a silent never-match. + * + * `method` accepts the same value passed to `client.call` - either the + * generated method namespace (`chat.bsky.convo.acceptConvo`) or its `.main` + * schema - via lex's `Main`. + */ +export function isXrpcErrorOf( + method: Main, + e: unknown, + name: NoInfer>, +): boolean { + return isXrpcError(e) && e.error === name +} + /** * Read a response header off an XRPC error. Only `XrpcResponseError` carries a * response; its `.headers` is a WHATWG `Headers` object. diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index 624bc66b00..6ae55cff46 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -140,6 +140,7 @@ export const LoginForm = ({ } catch (err) { const errMsg = String(err) setIsProcessing(false) + /* matches a LexAuthFactorError from PasswordSession.login (not an XrpcError), so getErrorName - gated on LexError, not isXrpcError - is required here */ if (getErrorName(err) === 'AuthFactorTokenRequired') { setIsAuthFactorTokenNeeded(true) } else { diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx index ebdbadb08d..a0aef230ba 100644 --- a/src/screens/Messages/ConversationSettings/index.tsx +++ b/src/screens/Messages/ConversationSettings/index.tsx @@ -14,7 +14,7 @@ import { type NativeStackScreenProps, type NavigationProp, } from '#/lib/routes/types' -import {getErrorName} from '#/lib/xrpc-error' +import {isXrpcErrorOf} from '#/lib/xrpc-error' import {logger} from '#/logger' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useConvoQuery} from '#/state/queries/messages/conversation' @@ -430,7 +430,9 @@ function SettingsHeader({ if (lock) { logger.error('Failed to lock group chat', {message: e}) Toast.show(l`Failed to lock group chat`, {type: 'error'}) - } else if (getErrorName(e) === 'ConvoLockedByModeration') { + } else if ( + isXrpcErrorOf(chat.bsky.convo.unlockConvo, e, 'ConvoLockedByModeration') + ) { Toast.show(l`This chat is locked by a moderation action`, { type: 'error', }) diff --git a/src/screens/Messages/JoinRequests.tsx b/src/screens/Messages/JoinRequests.tsx index 40a308f036..1692161f4c 100644 --- a/src/screens/Messages/JoinRequests.tsx +++ b/src/screens/Messages/JoinRequests.tsx @@ -11,7 +11,7 @@ import { type NativeStackScreenProps, type NavigationProp, } from '#/lib/routes/types' -import {getErrorName} from '#/lib/xrpc-error' +import {isXrpcErrorOf} from '#/lib/xrpc-error' import {logger} from '#/logger' import {ConvoProvider, useConvo} from '#/state/messages/convo' import {ConvoStatus} from '#/state/messages/convo/types' @@ -39,7 +39,7 @@ import * as ProfileCard from '#/components/ProfileCard' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' -import {type chat} from '#/lexicons' +import {chat} from '#/lexicons' import type * as bsky from '#/types/bsky' import {InviteLinkDialog} from './components/InviteLinkDialog' @@ -187,11 +187,29 @@ function JoinRequestsList({ let errorMessage = l`Failed to accept join request` if (isNetworkError(error)) { errorMessage = l`A network error occurred. Please check your internet connection.` - } else if (getErrorName(error) === 'InvalidConvo') { + } else if ( + isXrpcErrorOf( + chat.bsky.group.approveJoinRequest, + error, + 'InvalidConvo', + ) + ) { errorMessage = l`Conversation not found.` - } else if (getErrorName(error) === 'InsufficientRole') { + } else if ( + isXrpcErrorOf( + chat.bsky.group.approveJoinRequest, + error, + 'InsufficientRole', + ) + ) { errorMessage = l`Only admins can accept join requests.` - } else if (getErrorName(error) === 'MemberLimitReached') { + } else if ( + isXrpcErrorOf( + chat.bsky.group.approveJoinRequest, + error, + 'MemberLimitReached', + ) + ) { errorMessage = l`The member limit has been reached.` } Toast.show(errorMessage, {type: 'error'}) @@ -213,9 +231,21 @@ function JoinRequestsList({ let errorMessage = l`Failed to reject join request` if (isNetworkError(error)) { errorMessage = l`A network error occurred. Please check your internet connection.` - } else if (getErrorName(error) === 'InvalidConvo') { + } else if ( + isXrpcErrorOf( + chat.bsky.group.rejectJoinRequest, + error, + 'InvalidConvo', + ) + ) { errorMessage = l`Conversation not found.` - } else if (getErrorName(error) === 'InsufficientRole') { + } else if ( + isXrpcErrorOf( + chat.bsky.group.rejectJoinRequest, + error, + 'InsufficientRole', + ) + ) { errorMessage = l`Only admins can reject join requests.` } Toast.show(errorMessage, {type: 'error'}) diff --git a/src/screens/Messages/components/ChatLocked.tsx b/src/screens/Messages/components/ChatLocked.tsx index 03a3b128ba..051814c13f 100644 --- a/src/screens/Messages/components/ChatLocked.tsx +++ b/src/screens/Messages/components/ChatLocked.tsx @@ -4,7 +4,7 @@ import {useNavigation} from '@react-navigation/native' import {HITSLOP_10} from '#/lib/constants' import {type NavigationProp} from '#/lib/routes/types' -import {getErrorName} from '#/lib/xrpc-error' +import {isXrpcErrorOf} from '#/lib/xrpc-error' import {logger} from '#/logger' import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' import {useLockConvo} from '#/state/queries/messages/lock-conversation' @@ -15,6 +15,7 @@ import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' import * as Prompt from '#/components/Prompt' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {chat} from '#/lexicons' import {LeaveChatPrompt} from '../ConversationSettings/prompts' import {ChatFooter} from './ChatFooter' @@ -41,7 +42,9 @@ export function ChatLocked({ Toast.show(l({message: 'Group chat unlocked', context: 'toast'})) }, onError: e => { - if (getErrorName(e) === 'ConvoLockedByModeration') { + if ( + isXrpcErrorOf(chat.bsky.convo.unlockConvo, e, 'ConvoLockedByModeration') + ) { Toast.show(l`This chat is locked by a moderation action`, { type: 'error', }) diff --git a/src/screens/Messages/components/OutgoingRequestListItem.tsx b/src/screens/Messages/components/OutgoingRequestListItem.tsx index 13dba348d9..31a9e6bde3 100644 --- a/src/screens/Messages/components/OutgoingRequestListItem.tsx +++ b/src/screens/Messages/components/OutgoingRequestListItem.tsx @@ -2,7 +2,7 @@ import {View} from 'react-native' import {Trans, useLingui} from '@lingui/react/macro' import {isNetworkError} from '#/lib/strings/errors' -import {getErrorName} from '#/lib/xrpc-error' +import {isXrpcErrorOf} from '#/lib/xrpc-error' import {useWithdrawJoinGroupChatRequest} from '#/state/queries/messages/withdraw-join-group-chat' import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {atoms as a, useTheme, web} from '#/alf' @@ -11,7 +11,7 @@ import {createStaticClick, Link} from '#/components/Link' import * as Prompt from '#/components/Prompt' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' -import {type chat} from '#/lexicons' +import {chat} from '#/lexicons' export function OutgoingRequestListItem({ convo: convoView, @@ -32,7 +32,13 @@ export function OutgoingRequestListItem({ let errorMessage = l`Failed to rescind your request. Please try again.` if (isNetworkError(error)) { errorMessage = l`There was a problem with your internet connection, please try again` - } else if (getErrorName(error) === 'InvalidJoinRequest') { + } else if ( + isXrpcErrorOf( + chat.bsky.group.withdrawJoinRequest, + error, + 'InvalidJoinRequest', + ) + ) { errorMessage = l`Invalid rescind request.` } Toast.show(errorMessage) diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts index 47831ee85f..eb4c551ddf 100644 --- a/src/screens/Signup/state.ts +++ b/src/screens/Signup/state.ts @@ -7,11 +7,11 @@ import {DEFAULT_SERVICE} from '#/lib/constants' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' import {getAge} from '#/lib/strings/time' -import {getErrorName} from '#/lib/xrpc-error' +import {isXrpcErrorOf} from '#/lib/xrpc-error' import {useSessionApi} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' import {type AnalyticsContextType, useAnalytics} from '#/analytics' -import {type com} from '#/lexicons' +import {com} from '#/lexicons' export type ServiceDescription = com.atproto.server.describeServer.$OutputBody @@ -355,7 +355,13 @@ export function useSubmitSignup() { } catch (err) { const e = err as Error let errMsg = e.toString() - if (getErrorName(e) === 'InvalidInviteCode') { + if ( + isXrpcErrorOf( + com.atproto.server.createAccount, + e, + 'InvalidInviteCode', + ) + ) { dispatch({ type: 'setError', value: l`Invite code not accepted. Check that you input it correctly and try again.`, diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 1c30974845..a27ab6786d 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -73,7 +73,7 @@ import {useCallOnce} from '#/lib/once' import {type NavigationProp} from '#/lib/routes/types' import {cleanError} from '#/lib/strings/errors' import {colors} from '#/lib/styles' -import {getErrorName} from '#/lib/xrpc-error' +import {isXrpcErrorOf} from '#/lib/xrpc-error' import {logger} from '#/logger' import {useDialogStateControlContext} from '#/state/dialogs' import {emitPostCreated} from '#/state/events' @@ -745,7 +745,7 @@ export const ComposePost = ({ const getDraftSaveError = useCallback( (e: unknown): string => { - if (getErrorName(e) === 'DraftLimitReached') { + if (isXrpcErrorOf(app.bsky.draft.createDraft, e, 'DraftLimitReached')) { return l`You've reached the maximum number of drafts` } return l`Failed to save draft` diff --git a/src/view/com/composer/drafts/state/queries.ts b/src/view/com/composer/drafts/state/queries.ts index b0d7eda30d..5b7e6a46f8 100644 --- a/src/view/com/composer/drafts/state/queries.ts +++ b/src/view/com/composer/drafts/state/queries.ts @@ -5,7 +5,7 @@ import { } from '@tanstack/react-query' import {isNetworkError} from '#/lib/strings/errors' -import {getErrorName} from '#/lib/xrpc-error' +import {isXrpcErrorOf} from '#/lib/xrpc-error' import {useAppviewClient} from '#/state/session' import {type ComposerState} from '#/view/com/composer/state/composer' import {useAnalytics} from '#/analytics' @@ -209,7 +209,9 @@ export function useSaveDraftMutation() { }, onError: error => { // Check for draft limit error - if (getErrorName(error) === 'DraftLimitReached') { + if ( + isXrpcErrorOf(app.bsky.draft.createDraft, error, 'DraftLimitReached') + ) { logger.error('Draft limit reached', {safeMessage: error.message}) // Error will be handled by caller } else if (!isNetworkError(error)) { diff --git a/src/view/com/posts/PostFeedErrorMessage.tsx b/src/view/com/posts/PostFeedErrorMessage.tsx index bf545633f7..186ac99aa4 100644 --- a/src/view/com/posts/PostFeedErrorMessage.tsx +++ b/src/view/com/posts/PostFeedErrorMessage.tsx @@ -239,6 +239,11 @@ function detectKnownError( if (!error) { return undefined } + /* + * Generic helper over an arbitrary feed error (any feed descriptor). Both + * names are declared by app.bsky.feed.getAuthorFeed and .getActorLikes, so + * the source method is ambiguous - kept as an untyped getErrorName check. + */ if ( getErrorName(error) === 'BlockedActor' || getErrorName(error) === 'BlockedByActor'