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 type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server"; 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 { getCharacterBySlug } from "@/data/constants/character";
import { CharacterProvider } from "@/providers/character-provider"; import { CharacterProvider } from "@/providers/character-provider";
@@ -18,13 +18,27 @@ import { PrivateMessageCard } from "../private-message-card";
import { PwaInstallDialog } from "../pwa-install-dialog"; import { PwaInstallDialog } from "../pwa-install-dialog";
import { TextBubble } from "../text-bubble"; import { TextBubble } from "../text-bubble";
const userMocks = vi.hoisted(() => ({
avatarUrl: null as string | null,
}));
vi.mock("@/router/use-app-navigator", () => ({ vi.mock("@/router/use-app-navigator", () => ({
useAppNavigator: () => ({ useAppNavigator: () => ({
openSubscription: vi.fn(), 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", () => { describe("chat Tailwind components", () => {
beforeEach(() => {
userMocks.avatarUrl = null;
});
it("renders MessageAvatar AI and user branches with image utilities", () => { it("renders MessageAvatar AI and user branches with image utilities", () => {
const aiHtml = renderWithCharacter(<MessageAvatar isFromAI={true} />); const aiHtml = renderWithCharacter(<MessageAvatar isFromAI={true} />);
const userHtml = renderWithCharacter( const userHtml = renderWithCharacter(
@@ -228,6 +242,7 @@ describe("chat Tailwind components", () => {
expect(memberHtml).not.toContain("px-(--spacing-md,12px)"); expect(memberHtml).not.toContain("px-(--spacing-md,12px)");
expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]"); expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]");
expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)"); expect(memberHtml).toContain("size-(--responsive-icon-button-size,42px)");
expect(memberHtml).toContain("lucide-user-round");
expect(memberWithHintHtml).toContain( expect(memberWithHintHtml).toContain(
'data-analytics-key="chat.external_browser_hint"', '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", () => { it("links guest chat back to the active character splash", () => {
const maya = getCharacterBySlug("maya"); const maya = getCharacterBySlug("maya");
if (!maya) throw new Error("Missing Maya character fixture"); if (!maya) throw new Error("Missing Maya character fixture");
+18 -8
View File
@@ -2,17 +2,17 @@
/** /**
* ChatHeader 顶部栏 * ChatHeader 顶部栏
* *
* 图标:lucide-react <Lock />(游客 banner+ <UserRound />Profile * Profile 优先展示用户头像;头像缺失时回退到 lucide-react <UserRound />
* tree-shakablecurrentColor 继承父 color
*/ */
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Lock, UserRound } from "lucide-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 { useActiveCharacterRoutes } from "@/providers/character-provider";
import { buildGlobalPageUrl } from "@/router/global-route-context"; import { buildGlobalPageUrl } from "@/router/global-route-context";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import { useUserSelector } from "@/stores/user/user-context";
import { BrowserHintOverlay } from "./browser-hint-overlay"; import { BrowserHintOverlay } from "./browser-hint-overlay";
@@ -29,6 +29,10 @@ export function ChatHeader({
}: ChatHeaderProps) { }: ChatHeaderProps) {
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const characterRoutes = useActiveCharacterRoutes(); const characterRoutes = useActiveCharacterRoutes();
const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
const handleOpenProfile = () => {
navigator.push(buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat));
};
return ( return (
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0"> <header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
@@ -72,20 +76,26 @@ export function ChatHeader({
{showBrowserHint ? <BrowserHintOverlay /> : null} {showBrowserHint ? <BrowserHintOverlay /> : null}
</div> </div>
{avatarUrl ? (
<UserMessageAvatar
avatarUrl={avatarUrl}
size="var(--responsive-icon-button-size, 42px)"
actionLabel="Profile"
analyticsKey="chat.open_profile"
onClick={handleOpenProfile}
/>
) : (
<button <button
type="button" type="button"
data-analytics-key="chat.open_profile" data-analytics-key="chat.open_profile"
data-analytics-label="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]" 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={() => onClick={handleOpenProfile}
navigator.push(
buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat),
)
}
aria-label="Profile" aria-label="Profile"
> >
<UserRound size={24} aria-hidden="true" /> <UserRound size={24} aria-hidden="true" />
</button> </button>
)}
</> </>
) : null} ) : null}
</div> </div>