feat(profile)!: replace sidebar and add avatar navigation

Rename the global Sidebar route, UI, assets, analytics, and payment return context to Profile. Add accessible message-avatar navigation and preserve the source character across auth and logout flows.

BREAKING CHANGE: /sidebar has been removed; use /profile instead.
This commit is contained in:
2026-07-21 18:08:12 +08:00
parent 6cd3a0c2d2
commit 9deb320cf6
67 changed files with 565 additions and 256 deletions
+22 -1
View File
@@ -15,7 +15,9 @@ import {
} from "@/providers/character-provider";
import { useCharacterCatalog } from "@/providers/character-catalog-provider";
import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session";
import { getCharacterRoutes } from "@/router/routes";
import { buildGlobalPageUrl } from "@/router/global-route-context";
import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver";
import { getCharacterRoutes, ROUTES } from "@/router/routes";
import { MobileShell } from "@/app/_components/core";
@@ -57,6 +59,10 @@ export function ChatScreen() {
const refreshCharacterCatalog = characterCatalog.refresh;
const defaultCharacterSlug = characterCatalog.defaultCharacter.slug;
const characterRoutes = useActiveCharacterRoutes();
const profileUrl = buildGlobalPageUrl(
ROUTES.profile,
characterRoutes.chat,
);
const searchParams = useSearchParams();
const state = useChatState();
const chatDispatch = useChatDispatch();
@@ -234,6 +240,19 @@ export function ChatScreen() {
});
}
function handleOpenUserProfile(): void {
router.push(
resolveAuthenticatedNavigation({
loginStatus: authState.loginStatus,
targetUrl: profileUrl,
}),
);
}
function handleOpenCharacterPrivateRoom(): void {
router.push(characterRoutes.privateRoom);
}
return (
<MobileShell>
<div
@@ -280,6 +299,8 @@ export function ChatScreen() {
onUnlockVoiceMessage={handleUnlockVoiceMessage}
onUnlockImageMessage={handleUnlockImageMessage}
onOpenImage={handleOpenImage}
onUserAvatarClick={handleOpenUserProfile}
onCharacterAvatarClick={handleOpenCharacterPrivateRoom}
onLoadMoreHistory={() => {
chatDispatch({ type: "ChatLoadMoreHistoryRequested" });
}}
@@ -31,7 +31,6 @@ describe("chat Tailwind components", () => {
<MessageAvatar isFromAI={false} userAvatarUrl="/user-avatar.png" />,
);
expect(aiHtml).toContain('aria-label="AI avatar"');
expect(aiHtml).toContain("size-(--chat-avatar-size,43px)");
expect(aiHtml).toContain("size-full object-cover");
expect(aiHtml).toContain("%2Fimages%2Favatar%2Felio.png");
@@ -39,6 +38,28 @@ describe("chat Tailwind components", () => {
expect(userHtml).toContain("%2Fuser-avatar.png");
});
it("renders message avatar actions with explicit destinations", () => {
const aiHtml = renderWithCharacter(
<MessageAvatar isFromAI={true} onClick={() => undefined} />,
);
const userHtml = renderWithCharacter(
<MessageAvatar isFromAI={false} onClick={() => undefined} />,
);
expect(aiHtml).toContain('<button type="button"');
expect(aiHtml).toContain(
'data-analytics-key="chat.open_private_room_from_avatar"',
);
expect(aiHtml).toContain(
'aria-label="Open Elio Silvestri&#x27;s private room"',
);
expect(userHtml).toContain('<button type="button"');
expect(userHtml).toContain(
'data-analytics-key="chat.open_profile_from_avatar"',
);
expect(userHtml).toContain('aria-label="Open profile"');
});
it("renders the active character avatar", () => {
const maya = getCharacterBySlug("maya");
expect(maya).not.toBeNull();
@@ -196,12 +217,12 @@ describe("chat Tailwind components", () => {
expect(guestHtml).toContain('aria-label="Back to home"');
expect(guestHtml).toContain('data-analytics-key="chat.back_to_home"');
expect(guestHtml).toContain("px-(--chat-inline-padding,16px)");
expect(guestHtml).not.toContain('aria-label="Menu"');
expect(guestHtml).not.toContain('aria-label="Profile"');
expect(memberHtml).toContain("Offer");
expect(memberHtml).toContain('href="/characters/elio/splash"');
expect(memberHtml).toContain('aria-label="Back to home"');
expect(memberHtml).toContain('aria-label="Menu"');
expect(memberHtml).toContain('data-analytics-key="chat.open_menu"');
expect(memberHtml).toContain('aria-label="Profile"');
expect(memberHtml).toContain('data-analytics-key="chat.open_profile"');
expect(memberHtml).toContain('data-analytics-key="chat.back_to_home"');
expect(memberHtml).toContain("px-(--chat-inline-padding,16px)");
expect(memberHtml).not.toContain("px-(--spacing-md,12px)");
@@ -222,7 +243,7 @@ describe("chat Tailwind components", () => {
memberWithHintHtml.indexOf(
'data-analytics-key="chat.external_browser_hint"',
),
).toBeLessThan(memberWithHintHtml.indexOf('aria-label="Menu"'));
).toBeLessThan(memberWithHintHtml.indexOf('aria-label="Profile"'));
expect(guestWithHintHtml).not.toContain(
'data-analytics-key="chat.external_browser_hint"',
);
@@ -239,7 +260,7 @@ describe("chat Tailwind components", () => {
);
expect(html).toContain('href="/characters/maya/splash"');
expect(html).not.toContain('aria-label="Menu"');
expect(html).not.toContain('aria-label="Profile"');
expect(html).not.toContain(
'data-analytics-key="chat.external_browser_hint"',
);
+10
View File
@@ -59,6 +59,8 @@ export interface ChatAreaProps {
onUnlockImageMessage?: ChatMessageAction;
onOpenImage?: (displayMessageId: string) => void;
onLoadMoreHistory?: () => void;
onUserAvatarClick?: () => void;
onCharacterAvatarClick?: () => void;
}
type ChatMessageAction = (
@@ -81,6 +83,8 @@ export function ChatArea({
onUnlockImageMessage,
onOpenImage,
onLoadMoreHistory,
onUserAvatarClick,
onCharacterAvatarClick,
}: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
@@ -251,6 +255,8 @@ export function ChatArea({
onUnlockVoiceMessage,
onUnlockImageMessage,
onOpenImage,
onUserAvatarClick,
onCharacterAvatarClick,
)}
{isReplyingAI ? (
@@ -287,6 +293,8 @@ function renderMessagesWithDateHeaders(
onUnlockVoiceMessage?: ChatMessageAction,
onUnlockImageMessage?: ChatMessageAction,
onOpenImage?: (displayMessageId: string) => void,
onUserAvatarClick?: () => void,
onCharacterAvatarClick?: () => void,
) {
return buildChatRenderItems(messages, getMessageKey).map((item) =>
item.type === "date" ? (
@@ -314,6 +322,8 @@ function renderMessagesWithDateHeaders(
onUnlockVoiceMessage={onUnlockVoiceMessage}
onUnlockImageMessage={onUnlockImageMessage}
onOpenImage={onOpenImage}
onUserAvatarClick={onUserAvatarClick}
onCharacterAvatarClick={onCharacterAvatarClick}
/>
),
);
+7 -7
View File
@@ -2,11 +2,11 @@
/**
* ChatHeader 顶部栏
*
* 图标:lucide-react <Lock />(游客 banner+ <Menu />(菜单按钮
* 图标:lucide-react <Lock />(游客 banner+ <UserRound />Profile
* tree-shakablecurrentColor 继承父 color
*/
import type { ReactNode } from "react";
import { Lock, Menu } from "lucide-react";
import { Lock, UserRound } from "lucide-react";
import { BackButton } from "@/app/_components";
import { useActiveCharacterRoutes } from "@/providers/character-provider";
@@ -74,17 +74,17 @@ export function ChatHeader({
<button
type="button"
data-analytics-key="chat.open_menu"
data-analytics-label="Open chat menu"
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.sidebar, characterRoutes.chat),
buildGlobalPageUrl(ROUTES.profile, characterRoutes.chat),
)
}
aria-label="Menu"
aria-label="Profile"
>
<Menu size={24} aria-hidden="true" />
<UserRound size={24} aria-hidden="true" />
</button>
</>
) : null}
+40 -11
View File
@@ -1,32 +1,61 @@
"use client";
import Image from "next/image";
import { UserMessageAvatar } from "@/app/_components";
import { CharacterAvatar, UserMessageAvatar } from "@/app/_components";
import { useActiveCharacter } from "@/providers/character-provider";
export interface MessageAvatarProps {
isFromAI: boolean;
userAvatarUrl?: string | null;
onClick?: () => void;
}
const AVATAR_CLASS_NAME =
"flex size-(--chat-avatar-size,43px) shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-(--color-avatar-border,#fbf3f5) bg-(--color-avatar-border,#fbf3f5) shadow-(--shadow-input-box,0_1px_2px_rgba(0,0,0,0.1))";
"size-(--chat-avatar-size,43px) ring-2 ring-(--color-avatar-border,#fbf3f5) shadow-(--shadow-input-box,0_1px_2px_rgba(0,0,0,0.1))";
export function MessageAvatar({ isFromAI, userAvatarUrl }: MessageAvatarProps) {
export function MessageAvatar({
isFromAI,
userAvatarUrl,
onClick,
}: MessageAvatarProps) {
const character = useActiveCharacter();
if (isFromAI) {
return (
<div className={AVATAR_CLASS_NAME} aria-label="AI avatar">
<Image
if (onClick) {
return (
<CharacterAvatar
src={character.assets.avatar}
alt={character.displayName}
width={43}
height={43}
size="var(--chat-avatar-size, 43px)"
imageSize={43}
priority
className="size-full object-cover"
className={AVATAR_CLASS_NAME}
actionLabel={`Open ${character.displayName}'s private room`}
analyticsKey="chat.open_private_room_from_avatar"
onClick={onClick}
/>
</div>
);
}
return (
<CharacterAvatar
src={character.assets.avatar}
alt={character.displayName}
size="var(--chat-avatar-size, 43px)"
imageSize={43}
priority
className={AVATAR_CLASS_NAME}
/>
);
}
if (onClick) {
return (
<UserMessageAvatar
avatarUrl={userAvatarUrl}
actionLabel="Open profile"
analyticsKey="chat.open_profile_from_avatar"
onClick={onClick}
/>
);
}
+13 -2
View File
@@ -32,6 +32,8 @@ export interface MessageBubbleProps {
onUnlockVoiceMessage?: ChatMessageAction;
onUnlockImageMessage?: ChatMessageAction;
onOpenImage?: (displayMessageId: string) => void;
onUserAvatarClick?: () => void;
onCharacterAvatarClick?: () => void;
}
type ChatMessageAction = (
@@ -57,6 +59,8 @@ export function MessageBubble({
onUnlockVoiceMessage,
onUnlockImageMessage,
onOpenImage,
onUserAvatarClick,
onCharacterAvatarClick,
}: MessageBubbleProps) {
const avatarUrl = useUserSelector((state) => state.context.avatarUrl);
@@ -67,7 +71,10 @@ export function MessageBubble({
data-chat-message-id={displayMessageId}
aria-label="AI message"
>
<MessageAvatar isFromAI={true} />
<MessageAvatar
isFromAI={true}
onClick={onCharacterAvatarClick}
/>
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageContent
characterId={characterId}
@@ -120,7 +127,11 @@ export function MessageBubble({
onOpenImage={onOpenImage}
/>
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
<MessageAvatar
isFromAI={false}
userAvatarUrl={avatarUrl}
onClick={onUserAvatarClick}
/>
</div>
);
}