fix(desktop): put the server's profile errors on their fields

The profile form toasted the server's English text for a taken handle, a
blank name or a value too long, and its own limits differed from the
server's (a 50-character name, 64-character website). Update-user codes
now show their translated message under the field, the form checks the
server's lengths, and the handle lookup stays for servers without them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DIYgod
2026-10-12 23:16:29 +08:00
parent 6b262963fd
commit 47560a7597
3 changed files with 156 additions and 33 deletions
@@ -0,0 +1,62 @@
import enCommon from "@locales/common/en.json"
import enSettings from "@locales/settings/en.json"
import i18next from "i18next"
import { beforeAll, describe, expect, test } from "vitest"
import { getProfileFieldError, profileFormSchema } from "./profile-form-schema"
const profile = { handle: "folo", name: "Folo", image: "", bio: "", website: "" }
const errorOf = (values: Partial<typeof profile>) => {
const result = profileFormSchema.safeParse({ ...profile, ...values })
return result.success ? undefined : result.error.issues[0]!.message
}
describe("profileFormSchema", () => {
beforeAll(async () => {
await i18next.init({
lng: "en",
resources: { en: { common: enCommon, settings: enSettings } },
})
})
test("accepts what the server's columns hold", () => {
expect(errorOf({ name: "n".repeat(64) })).toBeUndefined()
expect(errorOf({ handle: "h".repeat(36) })).toBeUndefined()
expect(errorOf({ website: `https://example.com/${"a".repeat(200)}` })).toBeUndefined()
// The server counts an emoji as one character
expect(errorOf({ name: "😀".repeat(64) })).toBeUndefined()
})
test("refuses what the server would refuse, with the limit", () => {
expect(errorOf({ name: "n".repeat(65) })).toBe("Use at most 64 characters.")
expect(errorOf({ handle: "h".repeat(37) })).toBe("Use at most 36 characters.")
expect(errorOf({ bio: "b".repeat(257) })).toBe("Use at most 256 characters.")
expect(errorOf({ website: `https://example.com/${"a".repeat(240)}` })).toBe(
"Use at most 256 characters.",
)
})
})
describe("getProfileFieldError", () => {
test("puts the server's update-user codes on their fields", () => {
expect(getProfileFieldError("HANDLE_ALREADY_TAKEN")).toEqual({
field: "handle",
message: "This handle is already taken.",
})
expect(getProfileFieldError("NAME_CANNOT_BE_EMPTY")).toEqual({
field: "name",
message: "This field is required.",
})
expect(getProfileFieldError("WEBSITE_TOO_LONG")).toEqual({
field: "website",
message: "Use at most 256 characters.",
})
})
test("leaves other errors to the generic message", () => {
expect(getProfileFieldError("VALIDATION_ERROR")).toBeUndefined()
expect(getProfileFieldError("toString")).toBeUndefined()
expect(getProfileFieldError(undefined)).toBeUndefined()
})
})
@@ -0,0 +1,70 @@
import i18next from "i18next"
import { z } from "zod"
export const socialLinksSchema = z.object({
twitter: z.string().max(32).optional(),
github: z.string().max(32).optional(),
instagram: z.string().max(32).optional(),
facebook: z.string().max(32).optional(),
youtube: z.string().max(32).optional(),
discord: z.string().max(32).optional(),
})
// The sizes of the user table's columns, which the server checks before it saves a profile
const MAX_LENGTH = {
handle: 36,
name: 64,
image: 256,
bio: 256,
website: 256,
} as const
export type ProfileTextField = keyof typeof MAX_LENGTH
const tooLongMessage = (field: ProfileTextField) =>
i18next.t("validation.too_long", { ns: "common", count: MAX_LENGTH[field] })
// Counted as the server counts them, so an emoji is one character and not two UTF-16 units
const withMaxLength = (schema: z.ZodString, field: ProfileTextField) =>
schema.refine((value) => [...value].length <= MAX_LENGTH[field], {
error: () => tooLongMessage(field),
})
export const profileFormSchema = z.object({
handle: withMaxLength(
z
.string()
.regex(/^\w+$/, { error: () => i18next.t("profile.handle.invalid", { ns: "settings" }) }),
"handle",
)
.optional()
.or(z.literal("")),
name: withMaxLength(z.string().trim().min(1), "name"),
image: withMaxLength(z.string().url(), "image").or(z.literal("")).optional(),
bio: withMaxLength(z.string(), "bio").optional(),
website: withMaxLength(z.string().url(), "website").optional().or(z.literal("")),
socialLinks: socialLinksSchema.optional(),
})
// The codes the server's update-user answers a field it refuses with
const SERVER_FIELD_ERRORS: Record<string, { field: ProfileTextField; message: () => string }> = {
HANDLE_ALREADY_TAKEN: {
field: "handle",
message: () => i18next.t("profile.handle.taken", { ns: "settings" }),
},
NAME_CANNOT_BE_EMPTY: {
field: "name",
message: () => i18next.t("validation.required", { ns: "common" }),
},
HANDLE_TOO_LONG: { field: "handle", message: () => tooLongMessage("handle") },
NAME_TOO_LONG: { field: "name", message: () => tooLongMessage("name") },
IMAGE_TOO_LONG: { field: "image", message: () => tooLongMessage("image") },
BIO_TOO_LONG: { field: "bio", message: () => tooLongMessage("bio") },
WEBSITE_TOO_LONG: { field: "website", message: () => tooLongMessage("website") },
}
/** The form field and message for an update-user error code that names a refused field */
export const getProfileFieldError = (code: string | undefined) => {
const fieldError = code && Object.hasOwn(SERVER_FIELD_ERRORS, code) && SERVER_FIELD_ERRORS[code]
return fieldError ? { field: fieldError.field, message: fieldError.message() } : undefined
}
@@ -15,11 +15,10 @@ import { userActions } from "@follow/store/user/store"
import { cn } from "@follow/utils/utils"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation } from "@tanstack/react-query"
import i18next from "i18next"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { z } from "zod"
import type { z } from "zod"
import { AvatarUploadModal } from "~/components/ui/crop/AvatarUploadModal"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
@@ -28,28 +27,8 @@ import { updateUser } from "~/lib/auth"
import { uploadAvatarBlob } from "~/lib/avatar-upload"
import { toastFetchError } from "~/lib/error-parser"
const socialLinksSchema = z.object({
twitter: z.string().max(32).optional(),
github: z.string().max(32).optional(),
instagram: z.string().max(32).optional(),
facebook: z.string().max(32).optional(),
youtube: z.string().max(32).optional(),
discord: z.string().max(32).optional(),
})
const formSchema = z.object({
handle: z
.string()
.max(32)
.regex(/^\w+$/, { error: () => i18next.t("profile.handle.invalid", { ns: "settings" }) })
.optional()
.or(z.literal("")),
name: z.string().trim().min(1).max(50),
image: z.string().url().or(z.literal("")).optional(),
bio: z.string().max(256).optional(),
website: z.string().url().max(64).optional().or(z.literal("")),
socialLinks: socialLinksSchema.optional(),
})
import type { ProfileTextField, socialLinksSchema } from "./profile-form-schema"
import { getProfileFieldError, profileFormSchema } from "./profile-form-schema"
const socialIconClassNames = {
twitter: "i-mgc-twitter-cute-fi",
@@ -62,7 +41,14 @@ const socialIconClassNames = {
const formItemLabelClassName = tw`pl-3`
class HandleTakenError extends Error {}
class ProfileFieldError extends Error {
constructor(
readonly field: ProfileTextField,
message: string,
) {
super(message)
}
}
const isHandleTakenByOthers = async (handle: string, userId?: string) => {
try {
@@ -100,8 +86,8 @@ export const ProfileSettingForm = ({
const user = useWhoami() as ExtendedUser
const { present } = useModalStack()
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
const form = useForm<z.infer<typeof profileFormSchema>>({
resolver: zodResolver(profileFormSchema),
defaultValues: {
handle: user?.handle || "",
name: user?.name || "",
@@ -120,7 +106,7 @@ export const ProfileSettingForm = ({
})
const updateMutation = useMutation({
mutationFn: async (values: Partial<z.infer<typeof formSchema>>) => {
mutationFn: async (values: Partial<z.infer<typeof profileFormSchema>>) => {
const res = await updateUser({
handle: values.handle,
image: values.image,
@@ -131,21 +117,26 @@ export const ProfileSettingForm = ({
})
// The auth client resolves with the error instead of throwing
if (res.error) {
// The server answers a handle another user has with a bare 500, so look it up
const fieldError = getProfileFieldError(res.error.code)
if (fieldError) {
throw new ProfileFieldError(fieldError.field, fieldError.message)
}
// Servers without HANDLE_ALREADY_TAKEN answer a handle another user has with a bare 500,
// so look it up
if (
values.handle &&
values.handle.toLowerCase() !== user?.handle?.toLowerCase() &&
(await isHandleTakenByOthers(values.handle, user?.id))
) {
throw new HandleTakenError()
throw new ProfileFieldError("handle", t("profile.handle.taken"))
}
throw new Error(res.error.message || res.error.statusText)
}
return res
},
onError: (error) => {
if (error instanceof HandleTakenError) {
form.setError("handle", { message: t("profile.handle.taken") })
if (error instanceof ProfileFieldError) {
form.setError(error.field, { message: error.message })
return
}
toastFetchError(error)
@@ -162,7 +153,7 @@ export const ProfileSettingForm = ({
},
})
function onSubmit(values: z.infer<typeof formSchema>) {
function onSubmit(values: z.infer<typeof profileFormSchema>) {
// An empty handle field leaves the handle as it is
const { handle, ...rest } = values
updateMutation.mutate(handle ? values : rest)