feat(chat): show user avatar in profile action

Render the stored user avatar in the Chat Header when available while preserving the existing Profile icon fallback, navigation, accessibility label, and analytics.
This commit is contained in:
2026-07-21 18:36:45 +08:00
parent 9fff5b16fd
commit 7f271ef2e0
2 changed files with 57 additions and 18 deletions
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getCharacterBySlug } from "@/data/constants/character";
import { CharacterProvider } from "@/providers/character-provider";
@@ -18,13 +18,27 @@ import { PrivateMessageCard } from "../private-message-card";
import { PwaInstallDialog } from "../pwa-install-dialog";
import { TextBubble } from "../text-bubble";
const userMocks = vi.hoisted(() => ({
avatarUrl: null as string | null,
}));
vi.mock("@/router/use-app-navigator", () => ({
useAppNavigator: () => ({
openSubscription: vi.fn(),
}),
}));
vi.mock("@/stores/user/user-context", () => ({
useUserSelector: (
selector: (state: { context: { avatarUrl: string | null } }) => unknown,
) => selector({ context: { avatarUrl: userMocks.avatarUrl } }),
}));
describe("chat Tailwind components", () => {
beforeEach(() => {
userMocks.avatarUrl = null;
});
it("renders MessageAvatar AI and user branches with image utilities", () => {
const aiHtml = renderWithCharacter(<MessageAvatar isFromAI={true} />);
const userHtml = renderWithCharacter(
@@ -228,6 +242,7 @@ describe("chat Tailwind components", () => {
expect(memberHtml).not.toContain("px-(--spacing-md,12px)");
expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]");
expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)");
expect(memberHtml).toContain("lucide-user-round");
expect(memberWithHintHtml).toContain(
'data-analytics-key="chat.external_browser_hint"',
);
@@ -249,6 +264,20 @@ describe("chat Tailwind components", () => {
);
});
it("renders the user avatar in the ChatHeader Profile action", () => {
userMocks.avatarUrl = "/images/avatar/profile-user.png";
const html = renderWithCharacter(<ChatHeader isGuest={false} />);
expect(html).toContain("%2Fimages%2Favatar%2Fprofile-user.png");
expect(html).toContain('aria-label="Profile"');
expect(html).toContain('data-analytics-key="chat.open_profile"');
expect(html).toContain(
"var(--responsive-icon-button-size, 42px)",
);
expect(html).not.toContain("lucide-user-round");
});
it("links guest chat back to the active character splash", () => {
const maya = getCharacterBySlug("maya");
if (!maya) throw new Error("Missing Maya character fixture");
+18 -8
View File
@@ -2,17 +2,17 @@
/**
* ChatHeader 顶部栏
*
* 图标:lucide-react <Lock />(游客 banner+ <UserRound />Profile
* tree-shakablecurrentColor 继承父 color
* Profile 优先展示用户头像;头像缺失时回退到 lucide-react <UserRound />
*/
import type { ReactNode } from "react";
import { Lock, UserRound } from "lucide-react";
import { BackButton } from "@/app/_components";
import { BackButton, UserMessageAvatar } from "@/app/_components";
import { useActiveCharacterRoutes } from "@/providers/character-provider";
import { buildGlobalPageUrl } from "@/router/global-route-context";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import { useUserSelector } from "@/stores/user/user-context";
import { BrowserHintOverlay } from "./browser-hint-overlay";
@@ -29,6 +29,10 @@ export function ChatHeader({
}: ChatHeaderProps) {
const navigator = useAppNavigator();
const characterRoutes = useActiveCharacterRoutes();
const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
const handleOpenProfile = () => {
navigator.push(buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat));
};
return (
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
@@ -72,20 +76,26 @@ export function ChatHeader({
{showBrowserHint ? <BrowserHintOverlay /> : null}
</div>
{avatarUrl ? (
<UserMessageAvatar
avatarUrl={avatarUrl}
size="var(--responsive-icon-button-size, 42px)"
actionLabel="Profile"
analyticsKey="chat.open_profile"
onClick={handleOpenProfile}
/>
) : (
<button
type="button"
data-analytics-key="chat.open_profile"
data-analytics-label="Open profile"
className="flex size-(--responsive-icon-button-size,42px) cursor-pointer items-center justify-center rounded-full border border-[rgba(255,255,255,0.12)] bg-[rgba(13,11,20,0.7)] text-(--color-text-primary,#fff) shadow-[0_10px_24px_rgba(0,0,0,0.2)] backdrop-blur-md transition-[background,transform] duration-150 hover:bg-[rgba(28,24,39,0.82)] active:scale-96 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
onClick={() =>
navigator.push(
buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat),
)
}
onClick={handleOpenProfile}
aria-label="Profile"
>
<UserRound size={24} aria-hidden="true" />
</button>
)}
</>
) : null}
</div>