fix(settings): build the avatar initials from the display name

The settings avatar took its fallback letters and its alt text from the
login, while the top bar and the sidebar spell the display name, so the
same account showed different initials on two screens. The three
copies of the initials function become one `nameInitials()` beside
`accountName()`, and the settings avatar reads the same name.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 19:43:21 +01:00
1 parent 5cf4a7d213
commit 9a02dfff2e
4 files changed
+48 -32

No files matched your search

+4 -12
View File
@@ -40,7 +40,7 @@ import {
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { accountName } from "@/lib/account-name";
import { accountName, nameInitials } from "@/lib/account-name";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useNavModules } from "@/hooks/use-nav-modules";
import { useMounted } from "@/hooks/use-mounted";
@@ -98,14 +98,6 @@ export function writeSidebarCollapsedPref(collapsed: boolean): void {
}
}
function getInitials(name: string): string {
return name
.split(/[\s._-]+/)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? "")
.join("");
}
/**
* The sidebar footer's name-and-avatar block.
*
@@ -170,7 +162,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
<Avatar className="h-9 w-9 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(name)}
{nameInitials(name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
@@ -286,7 +278,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
<Avatar className="h-8 w-8" aria-hidden="true">
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(name)}
{nameInitials(name)}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
@@ -313,7 +305,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
<Avatar className="h-8 w-8 shrink-0" aria-hidden="true">
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(name)}
{nameInitials(name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
+2 -10
View File
@@ -26,7 +26,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import Link from "next/link";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { accountName } from "@/lib/account-name";
import { accountName, nameInitials } from "@/lib/account-name";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTheme } from "@/components/providers";
import { useTranslations } from "@/lib/i18n/context";
@@ -41,14 +41,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
function getInitials(name: string): string {
return name
.split(/[\s._-]+/)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? "")
.join("");
}
export function TopBar() {
const { user, isLoading } = useAuth();
// v1.36.0 — acting on somebody else's record; see the menu below. Read from
@@ -147,7 +139,7 @@ export function TopBar() {
<AvatarImage src={avatarUrl} alt={accountName(user)} />
)}
<AvatarFallback className="bg-primary/15 text-primary text-xs font-medium">
{getInitials(accountName(user))}
{nameInitials(accountName(user))}
</AvatarFallback>
</Avatar>
<span className="hidden sm:inline">{accountName(user)}</span>
@@ -0,0 +1,39 @@
/**
* The avatar fallback on the settings card spells the name the rest of the
* app shows. It used to take its letters from the login, so an account named
* "Robin Example" with the login "rex" read "RE" in the top bar and the
* sidebar and "R" here.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "rex",
displayName: "Robin Example",
avatarUrl: null,
},
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AvatarSection } from "../avatar-section";
describe("the settings avatar fallback", () => {
it("builds its initials from the display name, not the login", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AvatarSection />
</I18nProvider>,
);
expect(html).toContain(">RE<");
expect(html).not.toContain(">R<");
});
});
@@ -13,6 +13,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { statusText, type StatusMessage } from "./account-section-utils";
import { accountName, nameInitials } from "@/lib/account-name";
// Accepted upload types + byte cap mirror the server contract in
// `src/app/api/user/avatar/route.ts` so the client rejects an obvious
@@ -21,14 +22,6 @@ import { statusText, type StatusMessage } from "./account-section-utils";
const ACCEPTED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"];
const AVATAR_MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
function getAvatarInitials(name: string): string {
return name
.split(/[\s._-]+/)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? "")
.join("");
}
export function AvatarSection() {
const { t } = useTranslations();
const { user, refetch } = useAuth();
@@ -123,9 +116,9 @@ export function AvatarSection() {
</p>
<div className="flex items-center gap-4">
<Avatar className="size-16">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
{avatarUrl && <AvatarImage src={avatarUrl} alt={accountName(user)} />}
<AvatarFallback className="text-base">
{getAvatarInitials(user.username)}
{nameInitials(accountName(user))}
</AvatarFallback>
</Avatar>
<div className="flex flex-wrap gap-2">