diff --git a/src/app/chat/__tests__/chat-area.helpers.test.ts b/src/app/chat/__tests__/chat-area.helpers.test.ts new file mode 100644 index 00000000..584c8037 --- /dev/null +++ b/src/app/chat/__tests__/chat-area.helpers.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import type { UiMessage } from "@/data/dto/chat"; + +import { + buildChatRenderItems, + createChatMessageKeyResolver, +} from "../components/chat-area"; + +describe("chat area render items", () => { + it("keeps local message keys stable when history is prepended", () => { + const localMessage: UiMessage = { + content: "optimistic message", + isFromAI: false, + date: "2026-07-08", + }; + const historyMessage: UiMessage = { + id: "history-msg-1", + content: "history message", + isFromAI: true, + date: "2026-07-07", + }; + const getMessageKey = createChatMessageKeyResolver(); + + const firstItems = buildChatRenderItems([localMessage], getMessageKey); + const secondItems = buildChatRenderItems( + [historyMessage, localMessage], + getMessageKey, + ); + + expect(findMessageKey(firstItems, localMessage)).toBe("local-msg-1"); + expect(findMessageKey(secondItems, localMessage)).toBe("local-msg-1"); + expect(findMessageKey(secondItems, historyMessage)).toBe( + "msg-history-msg-1", + ); + }); +}); + +function findMessageKey( + items: ReturnType, + message: UiMessage, +): string | undefined { + return items.find((item) => item.type === "msg" && item.message === message) + ?.key; +} diff --git a/src/app/chat/__tests__/chat-screen.helpers.test.ts b/src/app/chat/__tests__/chat-screen.helpers.test.ts index c3fb5626..0f823316 100644 --- a/src/app/chat/__tests__/chat-screen.helpers.test.ts +++ b/src/app/chat/__tests__/chat-screen.helpers.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { getInsufficientCreditsMessageLimitView } from "../chat-screen.helpers"; +import { + getInsufficientCreditsMessageLimitView, + shouldShowMessageLimitBanner, +} from "../chat-screen.helpers"; describe("getInsufficientCreditsMessageLimitView", () => { it("asks guests to log in for more free chats", () => { @@ -33,3 +36,32 @@ describe("getInsufficientCreditsMessageLimitView", () => { }, ); }); + +describe("shouldShowMessageLimitBanner", () => { + it("shows only for visible insufficient credit prompts", () => { + expect( + shouldShowMessageLimitBanner({ + upgradePromptVisible: true, + upgradeReason: "insufficient_credits", + }), + ).toBe(true); + }); + + it("hides when the prompt is not visible", () => { + expect( + shouldShowMessageLimitBanner({ + upgradePromptVisible: false, + upgradeReason: "insufficient_credits", + }), + ).toBe(false); + }); + + it("hides when there is no upgrade reason", () => { + expect( + shouldShowMessageLimitBanner({ + upgradePromptVisible: true, + upgradeReason: null, + }), + ).toBe(false); + }); +}); diff --git a/src/app/chat/__tests__/use-chat-guest-login.test.ts b/src/app/chat/__tests__/use-chat-guest-login.test.ts new file mode 100644 index 00000000..becb0752 --- /dev/null +++ b/src/app/chat/__tests__/use-chat-guest-login.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { shouldSubmitGuestLogin } from "../hooks/use-chat-guest-login"; + +describe("shouldSubmitGuestLogin", () => { + it("submits once after auth initialization reaches notLoggedIn", () => { + expect( + shouldSubmitGuestLogin({ + alreadyRequested: false, + hasInitialized: true, + isLoading: false, + loginStatus: "notLoggedIn", + }), + ).toBe(true); + }); + + it("waits until auth initialization has completed", () => { + expect( + shouldSubmitGuestLogin({ + alreadyRequested: false, + hasInitialized: false, + isLoading: false, + loginStatus: "notLoggedIn", + }), + ).toBe(false); + }); + + it("does not submit while auth is already loading", () => { + expect( + shouldSubmitGuestLogin({ + alreadyRequested: false, + hasInitialized: true, + isLoading: true, + loginStatus: "notLoggedIn", + }), + ).toBe(false); + }); + + it("does not submit again for the same notLoggedIn window", () => { + expect( + shouldSubmitGuestLogin({ + alreadyRequested: true, + hasInitialized: true, + isLoading: false, + loginStatus: "notLoggedIn", + }), + ).toBe(false); + }); + + it("does not submit when the user already has a login status", () => { + expect( + shouldSubmitGuestLogin({ + alreadyRequested: false, + hasInitialized: true, + isLoading: false, + loginStatus: "guest", + }), + ).toBe(false); + }); +}); diff --git a/src/app/chat/chat-screen.helpers.ts b/src/app/chat/chat-screen.helpers.ts index ecc30eb0..c36ece04 100644 --- a/src/app/chat/chat-screen.helpers.ts +++ b/src/app/chat/chat-screen.helpers.ts @@ -1,6 +1,7 @@ "use client"; import type { LoginStatus } from "@/data/dto/auth"; +import type { ChatUpgradeReason } from "@/stores/chat/chat-state"; import { AppEnvUtil, BrowserDetector } from "@/utils"; export interface ExternalBrowserPromptState { @@ -18,10 +19,22 @@ export interface InsufficientCreditsMessageLimitView { action: InsufficientCreditsAction; } +export interface MessageLimitBannerState { + upgradePromptVisible: boolean; + upgradeReason: ChatUpgradeReason | null; +} + export function deriveIsGuest(loginStatus: LoginStatus): boolean { return loginStatus === "guest"; } +export function shouldShowMessageLimitBanner({ + upgradePromptVisible, + upgradeReason, +}: MessageLimitBannerState): boolean { + return upgradePromptVisible && upgradeReason === "insufficient_credits"; +} + export function getInsufficientCreditsMessageLimitView( loginStatus: LoginStatus, ): InsufficientCreditsMessageLimitView { diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 4cb4a918..4433d3c5 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -1,18 +1,10 @@ "use client"; -import { useEffect, useRef, useState } from "react"; import Image from "next/image"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; -import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; -import { useUserState } from "@/stores/user/user-context"; +import { useChatState } from "@/stores/chat/chat-context"; import { ROUTES } from "@/router/routes"; -import { useAppNavigator } from "@/router/use-app-navigator"; -import { - openChatInExternalBrowser, - recordExternalBrowserPromptShown, - resolveExternalBrowserPromptEligibility, -} from "@/lib/chat/chat_external_browser"; import { MobileShell } from "@/app/_components/core"; @@ -22,32 +14,26 @@ import { ChatHeader, ChatInputBar, ChatInsufficientCreditsBanner, + ChatUnlockDialogs, ExternalBrowserDialog, FirstRechargeOfferBanner, - HistoryUnlockDialog, - InsufficientCreditsDialog, PwaInstallOverlay, } from "./components"; import { deriveIsGuest, - getInsufficientCreditsMessageLimitView, - getInsufficientCreditsSubscriptionType, isChatDevelopmentEnvironment, - shouldStartExternalBrowserPrompt, } from "./chat-screen.helpers"; import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner"; import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-flow"; +import { useChatGuestLogin } from "./hooks/use-chat-guest-login"; +import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner"; +import { useExternalBrowserPrompt } from "./hooks/use-external-browser-prompt"; import styles from "./components/chat-screen.module.css"; export function ChatScreen() { const state = useChatState(); - const chatDispatch = useChatDispatch(); const authState = useAuthState(); const authDispatch = useAuthDispatch(); - const userState = useUserState(); - const navigator = useAppNavigator(); - const [showExternalBrowserDialog, setShowExternalBrowserDialog] = - useState(false); const { unlockPaywallRequest, requestMessageUnlock, @@ -59,100 +45,33 @@ export function ChatScreen() { const isGuest = deriveIsGuest(authState.loginStatus); // 发送能力由后端统一返回,当前只处理积分不足引导。 - const showMessageLimitBanner = - state.upgradePromptVisible && - state.upgradeReason === "insufficient_credits"; - const messageLimitView = getInsufficientCreditsMessageLimitView( - authState.loginStatus, - ); + const messageLimitBanner = useChatMessageLimitBanner({ + upgradePromptVisible: state.upgradePromptVisible, + upgradeReason: state.upgradeReason, + loginStatus: authState.loginStatus, + }); const firstRechargeOfferBanner = useFirstRechargeOfferBanner({ historyLoaded: state.historyLoaded, loginStatus: authState.loginStatus, }); const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10; + const externalBrowserPrompt = useExternalBrowserPrompt({ + hasInitialized: authState.hasInitialized, + isLoading: authState.isLoading, + loginStatus: authState.loginStatus, + }); - const externalBrowserPromptShownRef = useRef(false); - const guestLoginRequestedRef = useRef(false); - - useEffect(() => { - if (!authState.hasInitialized || authState.isLoading) return; - - if (authState.loginStatus !== "notLoggedIn") { - guestLoginRequestedRef.current = false; - return; - } - - if (guestLoginRequestedRef.current) return; - guestLoginRequestedRef.current = true; - authDispatch({ type: "AuthGuestLoginSubmitted" }); - }, [ - authDispatch, - authState.hasInitialized, - authState.isLoading, - authState.loginStatus, - ]); - - useEffect(() => { - if ( - !shouldStartExternalBrowserPrompt({ - hasInitialized: authState.hasInitialized, - isLoading: authState.isLoading, - loginStatus: authState.loginStatus, - }) - ) { - return; - } - - let mounted = true; - let timer: number | undefined; - - const init = async () => { - if (externalBrowserPromptShownRef.current) return; - - const eligibility = await resolveExternalBrowserPromptEligibility(); - if (!mounted || !eligibility.canShow) return; - - externalBrowserPromptShownRef.current = true; - timer = window.setTimeout(() => { - if (!mounted) return; - setShowExternalBrowserDialog(true); - void recordExternalBrowserPromptShown(eligibility.today); - }, 3000); - }; - - void init(); - - return () => { - mounted = false; - if (timer !== undefined) window.clearTimeout(timer); - }; - }, [ - authState.hasInitialized, - authState.isLoading, - authState.loginStatus, - ]); - - async function handleOpenExternalBrowser(): Promise { - setShowExternalBrowserDialog(false); - await openChatInExternalBrowser(); - } + useChatGuestLogin({ + dispatch: authDispatch, + hasInitialized: authState.hasInitialized, + isLoading: authState.isLoading, + loginStatus: authState.loginStatus, + }); function handleUnlockPrivateMessage(messageId: string): void { requestMessageUnlock(messageId, "private"); } - function handleMessageLimitUnlock(): void { - if (messageLimitView.action === "auth") { - navigator.openAuth(ROUTES.chat); - return; - } - - navigator.openSubscription({ - type: getInsufficientCreditsSubscriptionType(userState.isVip), - returnTo: "chat", - }); - } - function handleUnlockVoiceMessage(messageId: string): void { requestMessageUnlock(messageId, "voice"); } @@ -187,19 +106,18 @@ export function ChatScreen() { - {showMessageLimitBanner ? ( + {messageLimitBanner.visible ? ( ) : ( @@ -213,27 +131,15 @@ export function ChatScreen() { setShowExternalBrowserDialog(false)} - onConfirm={() => void handleOpenExternalBrowser()} + open={externalBrowserPrompt.open} + onClose={externalBrowserPrompt.close} + onConfirm={() => void externalBrowserPrompt.confirm()} /> - chatDispatch({ type: "ChatUnlockHistoryDismissed" })} - onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })} - /> - - diff --git a/src/app/chat/components/chat-area.module.css b/src/app/chat/components/chat-area.module.css index 336c5a82..eef83486 100644 --- a/src/app/chat/components/chat-area.module.css +++ b/src/app/chat/components/chat-area.module.css @@ -59,6 +59,32 @@ gap: var(--spacing-1, 4px); } +.bubbleInlineSpacer { + flex: 0 0 var(--spacing-sm, 8px); + width: var(--spacing-sm, 8px); +} + +.bubbleAvatarSpacer { + flex: 0 0 var(--chat-avatar-size, 43px); + width: var(--chat-avatar-size, 43px); +} + +.messageContent { + display: flex; + flex: 1 1 0; + min-width: 0; + flex-direction: column; + gap: var(--spacing-sm, 8px); +} + +.messageContentAi { + align-items: flex-start; +} + +.messageContentUser { + align-items: flex-end; +} + .bubbleAi { max-width: var(--chat-bubble-max-width, 75%); background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08)); diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index c09a9e43..8cfb0953 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -12,7 +12,7 @@ * - 新消息到达时自动滚到底部 * - 用户向上滚动时不强制拉回(保持阅读上下文) */ -import { useEffect, useLayoutEffect, useRef } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef } from "react"; import type { UiMessage } from "@/data/dto/chat"; @@ -31,11 +31,14 @@ import styles from "./chat-area.module.css"; const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96; type RestoreState = "idle" | "checking" | "restoring" | "settlingBottom" | "done"; +type ChatMessageKeyResolver = (message: UiMessage) => string; +type ChatRenderItem = + | { type: "date"; date: string; key: string } + | { type: "msg"; message: UiMessage; key: string }; export interface ChatAreaProps { messages: readonly UiMessage[]; isReplyingAI: boolean; - isGuest: boolean; isUnlockingMessage?: boolean; unlockingMessageId?: string | null; onUnlockPrivateMessage?: (messageId: string) => void; @@ -55,6 +58,7 @@ export function ChatArea({ const restoredScrollRef = useRef(false); const restoreStateRef = useRef("idle"); const wasNearBottomRef = useRef(true); + const getMessageKey = useMemo(() => createChatMessageKeyResolver(), []); const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => { const scrollNode = scrollRef.current; @@ -160,6 +164,7 @@ export function ChatArea({ {renderMessagesWithDateHeaders( messages, + getMessageKey, isUnlockingMessage, unlockingMessageId, onUnlockPrivateMessage, @@ -221,21 +226,13 @@ function startStableScrollTask({ /** 渲染消息列表(按日期分组插入分隔条) */ function renderMessagesWithDateHeaders( messages: readonly UiMessage[], + getMessageKey: (message: UiMessage) => string, isUnlockingMessage?: boolean, unlockingMessageId?: string | null, onUnlockPrivateMessage?: (messageId: string) => void, onUnlockVoiceMessage?: (messageId: string) => void, ) { - const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = []; - - messages.forEach((m, i) => { - if (i === 0 || m.date !== messages[i - 1].date) { - items.push({ type: "date", date: m.date, key: `d-${i}-${m.date}` }); - } - items.push({ type: "msg", message: m, key: `m-${i}` }); - }); - - return items.map((item) => + return buildChatRenderItems(messages, getMessageKey).map((item) => item.type === "date" ? ( ) : ( @@ -261,3 +258,41 @@ function renderMessagesWithDateHeaders( ), ); } + +export function createChatMessageKeyResolver(): ChatMessageKeyResolver { + const localMessageKeys = new WeakMap(); + let nextLocalMessageKey = 0; + + return (message) => { + if (message.id && message.id.length > 0) return `msg-${message.id}`; + + const existing = localMessageKeys.get(message); + if (existing) return existing; + + nextLocalMessageKey += 1; + const next = `local-msg-${nextLocalMessageKey}`; + localMessageKeys.set(message, next); + return next; + }; +} + +export function buildChatRenderItems( + messages: readonly UiMessage[], + getMessageKey: ChatMessageKeyResolver, +): ChatRenderItem[] { + const items: ChatRenderItem[] = []; + + messages.forEach((message, index) => { + const messageKey = getMessageKey(message); + if (index === 0 || message.date !== messages[index - 1].date) { + items.push({ + type: "date", + date: message.date, + key: `date-${message.date}-${messageKey}`, + }); + } + items.push({ type: "msg", message, key: messageKey }); + }); + + return items; +} diff --git a/src/app/chat/components/chat-unlock-dialogs.tsx b/src/app/chat/components/chat-unlock-dialogs.tsx new file mode 100644 index 00000000..200dfbf0 --- /dev/null +++ b/src/app/chat/components/chat-unlock-dialogs.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; +import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state"; + +import { HistoryUnlockDialog } from "./history-unlock-dialog"; +import { InsufficientCreditsDialog } from "./insufficient-credits-dialog"; + +export interface ChatUnlockDialogsProps { + unlockPaywallRequest: ChatUnlockPaywallRequest | null; + onCloseInsufficientCreditsDialog: () => void; + onConfirmInsufficientCreditsDialog: () => void; +} + +export function ChatUnlockDialogs({ + unlockPaywallRequest, + onCloseInsufficientCreditsDialog, + onConfirmInsufficientCreditsDialog, +}: ChatUnlockDialogsProps) { + const chatState = useChatState(); + const chatDispatch = useChatDispatch(); + + return ( + <> + chatDispatch({ type: "ChatUnlockHistoryDismissed" })} + onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })} + /> + + + ); +} diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index b5ba34ff..e8da0746 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -10,6 +10,7 @@ export * from "./chat-insufficient-credits-banner"; export * from "./chat-input-bar"; export * from "./chat-input-text-field"; export * from "./chat-send-button"; +export * from "./chat-unlock-dialogs"; export * from "./date-header"; export * from "./external-browser-dialog"; export * from "./first-recharge-offer-banner"; diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index c09c2afb..d1a7dc9c 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -55,7 +55,7 @@ export function MessageBubble({ aria-label="AI message" > -
+