fix(mobile): name the invalid field when the server rejects a request

A rejected save, such as an Actions rule with an invalid webhook URL, only
flashed "Unprocessable content" for 1.5 s. Show the server's message that
names the field under it, keep such toasts up for 5 s, and let the iOS
toast wrap that line instead of cutting it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DIYgod
2026-10-12 16:31:31 +08:00
parent fbe1d7ccce
commit cf87567573
6 changed files with 69 additions and 10 deletions
@@ -36,7 +36,9 @@ public enum Toast {
direction: options.position?.position() ?? .top,
dismissBy: [.time(time: TimeInterval(options.duration))]
)
ToastViewSwift.Toast.default(image: options.type.image(), title: options.title, subtitle: options.message, config: config)
// Let the message wrap instead of cutting off a server error that names the field
let viewConfig = ToastViewConfiguration(subtitleNumberOfLines: 0)
ToastViewSwift.Toast.default(image: options.type.image(), title: options.title, subtitle: options.message, viewConfig: viewConfig, config: config)
.show(haptic: options.type.haptic())
}
}
@@ -11,7 +11,12 @@ import type { ToastProps } from "./types"
export const CenteredToast = (props: ToastProps) => {
const renderMessage = props.render ? null : props.message ? (
<Text className="font-semibold text-white">{props.message}</Text>
<View className="shrink">
<Text className="font-semibold text-white">{props.message}</Text>
{!!props.description && (
<Text className="mt-0.5 text-sm text-white/80">{props.description}</Text>
)}
</View>
) : null
const { register } = use(ToastActionContext)
useEffect(() => {
+3 -1
View File
@@ -3,6 +3,8 @@ export interface ToastProps {
variant: "bottom-stack" | "center-replace"
type: "success" | "error" | "info"
message: string
/** A second line under the message, such as the reason a request failed */
description?: string
render: React.ReactNode
action: {
@@ -18,7 +20,7 @@ export interface ToastProps {
}
export type CenterToastProps = Partial<
Pick<ToastProps, "message" | "render" | "type" | "duration" | "icon">
Pick<ToastProps, "message" | "description" | "render" | "type" | "duration" | "icon">
> & {
variant: "center-replace"
}
+33
View File
@@ -14,6 +14,9 @@ vi.mock("i18next", () => ({
if (key === "errors:2012") {
return "RSSHub feed subscription limit exceeded"
}
if (key === "errors:3") {
return "Unprocessable content"
}
return key.replace(/^errors:/u, "")
},
}))
@@ -78,6 +81,36 @@ describe("toastFetchError", () => {
})
})
describe("toastFetchError for a rejected request body", () => {
beforeEach(() => {
mocks.toastError.mockClear()
})
it("names the field the server rejected under the translated code", () => {
toastFetchError(
new FollowAPIError(
"`rules.0.result.webhooks.0`: Invalid URL\nRequest: PUT /actions (original: /actions)\nArgs: {}",
422,
"3",
),
)
expect(mocks.toastError).toHaveBeenCalledWith("Unprocessable content", {
description: "`rules.0.result.webhooks.0`: Invalid URL",
})
})
it("names the field under a title", () => {
toastFetchError(new FollowAPIError("`rules.0.condition`: Invalid input", 422, "3"), {
title: "Failed to save",
})
expect(mocks.toastError).toHaveBeenCalledWith("Failed to save", {
description: "`rules.0.condition`: Invalid input",
})
})
})
describe("getFetchErrorMessage", () => {
it("describes a request that never reached the server instead of the native reason", () => {
const error = new Error(
+13 -2
View File
@@ -1,4 +1,4 @@
import { FollowAPIError } from "@follow-app/client-sdk"
import { ExceptionCodeMap, FollowAPIError } from "@follow-app/client-sdk"
import { t } from "i18next"
import { FetchError } from "ofetch"
@@ -80,6 +80,8 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
? t("common:app.api_unreachable")
: sanitizeErrorMessage(error.message)
let message = fallbackMessage
// What the server said, kept next to the translation of its error code
let serverMessage = ""
let _reason = ""
let code: number | undefined
let status: number | undefined
@@ -100,6 +102,7 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
code = typeof _code === "number" ? _code : Number(_code)
}
message = typeof _message === "string" && _message.trim() ? _message : message
serverMessage = typeof _message === "string" ? _message : ""
if (code != null) {
const tValue = t(`errors:${code}` as any)
if (tValue !== `${code}`) {
@@ -120,6 +123,7 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
code = Number(error.code)
}
status = error.status ? Number(error.status) : status
serverMessage = error.message
try {
if (error.code) {
const tValue = t(`errors:${code}` as any)
@@ -134,6 +138,7 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
}
message = sanitizeErrorMessage(message)
serverMessage = sanitizeErrorMessage(serverMessage)
// 2fa errors are handled by the form
if (code === 4007 || code === 4008) {
@@ -156,9 +161,15 @@ export const toastFetchError = (error: Error, { title: _title }: { title?: strin
return
}
// Every request validation failure has this code; only the server's message names the field
const detail =
code === ExceptionCodeMap.UnprocessableContent && serverMessage && serverMessage !== message
? serverMessage
: ""
if (!_reason) {
const title = _title || message || "Unknown error"
return toast.error(title)
return detail ? toast.error(title, { description: detail }) : toast.error(title)
} else {
return toast.error(message || _title || "Unknown error")
}
+11 -5
View File
@@ -7,7 +7,7 @@ import type { ToastProps } from "../components/ui/toast/types"
export const toastInstance = new ToastManager()
type CommandToastOptions = Partial<
Pick<ToastProps, "duration" | "icon" | "render" | "message" | "position">
Pick<ToastProps, "duration" | "icon" | "render" | "message" | "description" | "position">
>
type Toast = {
// [key in "error" | "success" | "info"]: (message: string) => void;
@@ -27,18 +27,24 @@ interface NativeToasterOptions {
duration?: number
position?: "top" | "center" | "bottom"
}
// A second line takes longer to read than the default short toast allows
const DEFAULT_DURATION = 1500
const DESCRIBED_DURATION = 5000
export const toast = {
show: toastInstance.show.bind(toastInstance),
} as Toast
;(["error", "success", "info"] as const).forEach((type) => {
toast[type] = (message: string, options: CommandToastOptions = {}) => {
const duration =
options.duration ?? (options.description ? DESCRIBED_DURATION : DEFAULT_DURATION)
if (Platform.OS === "ios") {
const NativeToaster = requireNativeModule("Toaster")
NativeToaster.toast({
// title: message,
message,
// The native toast shows the title in bold above the message
...(options.description ? { title: message, message: options.description } : { message }),
type,
duration: options.duration ? options.duration / 1000 : 1.5,
duration: duration / 1000,
position: options.position,
} as NativeToasterOptions)
return
@@ -48,8 +54,8 @@ export const toast = {
type,
message,
variant: "center-replace",
duration: options.duration ?? 1500,
...options,
duration,
})
}
})