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:
@@ -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");
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
/**
|
/**
|
||||||
* ChatHeader 顶部栏
|
* ChatHeader 顶部栏
|
||||||
*
|
*
|
||||||
* 图标:lucide-react <Lock />(游客 banner)+ <UserRound />(Profile)
|
* Profile 优先展示用户头像;头像缺失时回退到 lucide-react <UserRound />。
|
||||||
* tree-shakable,currentColor 继承父 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>
|
||||||
|
|
||||||
<button
|
{avatarUrl ? (
|
||||||
type="button"
|
<UserMessageAvatar
|
||||||
data-analytics-key="chat.open_profile"
|
avatarUrl={avatarUrl}
|
||||||
data-analytics-label="Open profile"
|
size="var(--responsive-icon-button-size, 42px)"
|
||||||
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]"
|
actionLabel="Profile"
|
||||||
onClick={() =>
|
analyticsKey="chat.open_profile"
|
||||||
navigator.push(
|
onClick={handleOpenProfile}
|
||||||
buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat),
|
/>
|
||||||
)
|
) : (
|
||||||
}
|
<button
|
||||||
aria-label="Profile"
|
type="button"
|
||||||
>
|
data-analytics-key="chat.open_profile"
|
||||||
<UserRound size={24} aria-hidden="true" />
|
data-analytics-label="Open profile"
|
||||||
</button>
|
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={handleOpenProfile}
|
||||||
|
aria-label="Profile"
|
||||||
|
>
|
||||||
|
<UserRound size={24} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user