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:
@@ -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'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"',
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
/**
|
||||
* ChatHeader 顶部栏
|
||||
*
|
||||
* 图标:lucide-react <Lock />(游客 banner)+ <Menu />(菜单按钮)
|
||||
* 图标:lucide-react <Lock />(游客 banner)+ <UserRound />(Profile)
|
||||
* tree-shakable,currentColor 继承父 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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user