refactor(chat): split screen orchestration
Docker Image / Build and Push Docker Image (push) Successful in 14m9s

This commit is contained in:
2026-07-08 15:33:24 +08:00
parent 18aa69bdd9
commit 21b6d346bd
15 changed files with 522 additions and 179 deletions
@@ -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<typeof buildChatRenderItems>,
message: UiMessage,
): string | undefined {
return items.find((item) => item.type === "msg" && item.message === message)
?.key;
}
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getInsufficientCreditsMessageLimitView } from "../chat-screen.helpers"; import {
getInsufficientCreditsMessageLimitView,
shouldShowMessageLimitBanner,
} from "../chat-screen.helpers";
describe("getInsufficientCreditsMessageLimitView", () => { describe("getInsufficientCreditsMessageLimitView", () => {
it("asks guests to log in for more free chats", () => { 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);
});
});
@@ -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);
});
});
+13
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import type { LoginStatus } from "@/data/dto/auth"; import type { LoginStatus } from "@/data/dto/auth";
import type { ChatUpgradeReason } from "@/stores/chat/chat-state";
import { AppEnvUtil, BrowserDetector } from "@/utils"; import { AppEnvUtil, BrowserDetector } from "@/utils";
export interface ExternalBrowserPromptState { export interface ExternalBrowserPromptState {
@@ -18,10 +19,22 @@ export interface InsufficientCreditsMessageLimitView {
action: InsufficientCreditsAction; action: InsufficientCreditsAction;
} }
export interface MessageLimitBannerState {
upgradePromptVisible: boolean;
upgradeReason: ChatUpgradeReason | null;
}
export function deriveIsGuest(loginStatus: LoginStatus): boolean { export function deriveIsGuest(loginStatus: LoginStatus): boolean {
return loginStatus === "guest"; return loginStatus === "guest";
} }
export function shouldShowMessageLimitBanner({
upgradePromptVisible,
upgradeReason,
}: MessageLimitBannerState): boolean {
return upgradePromptVisible && upgradeReason === "insufficient_credits";
}
export function getInsufficientCreditsMessageLimitView( export function getInsufficientCreditsMessageLimitView(
loginStatus: LoginStatus, loginStatus: LoginStatus,
): InsufficientCreditsMessageLimitView { ): InsufficientCreditsMessageLimitView {
+33 -127
View File
@@ -1,18 +1,10 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; import { useChatState } from "@/stores/chat/chat-context";
import { useUserState } from "@/stores/user/user-context";
import { ROUTES } from "@/router/routes"; 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"; import { MobileShell } from "@/app/_components/core";
@@ -22,32 +14,26 @@ import {
ChatHeader, ChatHeader,
ChatInputBar, ChatInputBar,
ChatInsufficientCreditsBanner, ChatInsufficientCreditsBanner,
ChatUnlockDialogs,
ExternalBrowserDialog, ExternalBrowserDialog,
FirstRechargeOfferBanner, FirstRechargeOfferBanner,
HistoryUnlockDialog,
InsufficientCreditsDialog,
PwaInstallOverlay, PwaInstallOverlay,
} from "./components"; } from "./components";
import { import {
deriveIsGuest, deriveIsGuest,
getInsufficientCreditsMessageLimitView,
getInsufficientCreditsSubscriptionType,
isChatDevelopmentEnvironment, isChatDevelopmentEnvironment,
shouldStartExternalBrowserPrompt,
} from "./chat-screen.helpers"; } from "./chat-screen.helpers";
import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner"; import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner";
import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-flow"; 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"; import styles from "./components/chat-screen.module.css";
export function ChatScreen() { export function ChatScreen() {
const state = useChatState(); const state = useChatState();
const chatDispatch = useChatDispatch();
const authState = useAuthState(); const authState = useAuthState();
const authDispatch = useAuthDispatch(); const authDispatch = useAuthDispatch();
const userState = useUserState();
const navigator = useAppNavigator();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false);
const { const {
unlockPaywallRequest, unlockPaywallRequest,
requestMessageUnlock, requestMessageUnlock,
@@ -59,100 +45,33 @@ export function ChatScreen() {
const isGuest = deriveIsGuest(authState.loginStatus); const isGuest = deriveIsGuest(authState.loginStatus);
// 发送能力由后端统一返回,当前只处理积分不足引导。 // 发送能力由后端统一返回,当前只处理积分不足引导。
const showMessageLimitBanner = const messageLimitBanner = useChatMessageLimitBanner({
state.upgradePromptVisible && upgradePromptVisible: state.upgradePromptVisible,
state.upgradeReason === "insufficient_credits"; upgradeReason: state.upgradeReason,
const messageLimitView = getInsufficientCreditsMessageLimitView( loginStatus: authState.loginStatus,
authState.loginStatus, });
);
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({ const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
historyLoaded: state.historyLoaded, historyLoaded: state.historyLoaded,
loginStatus: authState.loginStatus, loginStatus: authState.loginStatus,
}); });
const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10; const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10;
const externalBrowserPrompt = useExternalBrowserPrompt({
hasInitialized: authState.hasInitialized,
isLoading: authState.isLoading,
loginStatus: authState.loginStatus,
});
const externalBrowserPromptShownRef = useRef(false); useChatGuestLogin({
const guestLoginRequestedRef = useRef(false); dispatch: authDispatch,
hasInitialized: authState.hasInitialized,
useEffect(() => { isLoading: authState.isLoading,
if (!authState.hasInitialized || authState.isLoading) return; loginStatus: authState.loginStatus,
});
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<void> {
setShowExternalBrowserDialog(false);
await openChatInExternalBrowser();
}
function handleUnlockPrivateMessage(messageId: string): void { function handleUnlockPrivateMessage(messageId: string): void {
requestMessageUnlock(messageId, "private"); 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 { function handleUnlockVoiceMessage(messageId: string): void {
requestMessageUnlock(messageId, "voice"); requestMessageUnlock(messageId, "voice");
} }
@@ -187,19 +106,18 @@ export function ChatScreen() {
<ChatArea <ChatArea
messages={state.messages} messages={state.messages}
isReplyingAI={state.isReplyingAI} isReplyingAI={state.isReplyingAI}
isGuest={isGuest}
isUnlockingMessage={state.isUnlockingMessage} isUnlockingMessage={state.isUnlockingMessage}
unlockingMessageId={state.unlockingMessageId} unlockingMessageId={state.unlockingMessageId}
onUnlockPrivateMessage={handleUnlockPrivateMessage} onUnlockPrivateMessage={handleUnlockPrivateMessage}
onUnlockVoiceMessage={handleUnlockVoiceMessage} onUnlockVoiceMessage={handleUnlockVoiceMessage}
/> />
{showMessageLimitBanner ? ( {messageLimitBanner.visible ? (
<ChatInsufficientCreditsBanner <ChatInsufficientCreditsBanner
title={messageLimitView.title} title={messageLimitBanner.title}
description={messageLimitView.description} description={messageLimitBanner.description}
ctaLabel={messageLimitView.ctaLabel} ctaLabel={messageLimitBanner.ctaLabel}
onUnlock={handleMessageLimitUnlock} onUnlock={messageLimitBanner.unlock}
/> />
) : ( ) : (
<ChatInputBar disabled={!state.historyLoaded} /> <ChatInputBar disabled={!state.historyLoaded} />
@@ -213,27 +131,15 @@ export function ChatScreen() {
<PwaInstallOverlay enabled={shouldShowPwaInstall} /> <PwaInstallOverlay enabled={shouldShowPwaInstall} />
<ExternalBrowserDialog <ExternalBrowserDialog
open={showExternalBrowserDialog} open={externalBrowserPrompt.open}
onClose={() => setShowExternalBrowserDialog(false)} onClose={externalBrowserPrompt.close}
onConfirm={() => void handleOpenExternalBrowser()} onConfirm={() => void externalBrowserPrompt.confirm()}
/> />
<HistoryUnlockDialog <ChatUnlockDialogs
open={state.unlockHistoryPromptVisible} unlockPaywallRequest={unlockPaywallRequest}
lockedCount={state.lockedHistoryCount} onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
isLoading={state.isUnlockingHistory} onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
errorMessage={state.unlockHistoryError}
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
/>
<InsufficientCreditsDialog
open={unlockPaywallRequest !== null}
creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0}
shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0}
onClose={closeInsufficientCreditsDialog}
onConfirm={confirmInsufficientCreditsDialog}
/> />
</div> </div>
</MobileShell> </MobileShell>
@@ -59,6 +59,32 @@
gap: var(--spacing-1, 4px); 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 { .bubbleAi {
max-width: var(--chat-bubble-max-width, 75%); max-width: var(--chat-bubble-max-width, 75%);
background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08)); background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
+47 -12
View File
@@ -12,7 +12,7 @@
* - 新消息到达时自动滚到底部 * - 新消息到达时自动滚到底部
* - 用户向上滚动时不强制拉回(保持阅读上下文) * - 用户向上滚动时不强制拉回(保持阅读上下文)
*/ */
import { useEffect, useLayoutEffect, useRef } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
import type { UiMessage } from "@/data/dto/chat"; 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; const CHAT_AUTO_SCROLL_BOTTOM_THRESHOLD = 96;
type RestoreState = "idle" | "checking" | "restoring" | "settlingBottom" | "done"; 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 { export interface ChatAreaProps {
messages: readonly UiMessage[]; messages: readonly UiMessage[];
isReplyingAI: boolean; isReplyingAI: boolean;
isGuest: boolean;
isUnlockingMessage?: boolean; isUnlockingMessage?: boolean;
unlockingMessageId?: string | null; unlockingMessageId?: string | null;
onUnlockPrivateMessage?: (messageId: string) => void; onUnlockPrivateMessage?: (messageId: string) => void;
@@ -55,6 +58,7 @@ export function ChatArea({
const restoredScrollRef = useRef(false); const restoredScrollRef = useRef(false);
const restoreStateRef = useRef<RestoreState>("idle"); const restoreStateRef = useRef<RestoreState>("idle");
const wasNearBottomRef = useRef(true); const wasNearBottomRef = useRef(true);
const getMessageKey = useMemo(() => createChatMessageKeyResolver(), []);
const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => { const saveCurrentScrollSnapshotNow = (anchorMessageId: string) => {
const scrollNode = scrollRef.current; const scrollNode = scrollRef.current;
@@ -160,6 +164,7 @@ export function ChatArea({
{renderMessagesWithDateHeaders( {renderMessagesWithDateHeaders(
messages, messages,
getMessageKey,
isUnlockingMessage, isUnlockingMessage,
unlockingMessageId, unlockingMessageId,
onUnlockPrivateMessage, onUnlockPrivateMessage,
@@ -221,21 +226,13 @@ function startStableScrollTask({
/** 渲染消息列表(按日期分组插入分隔条) */ /** 渲染消息列表(按日期分组插入分隔条) */
function renderMessagesWithDateHeaders( function renderMessagesWithDateHeaders(
messages: readonly UiMessage[], messages: readonly UiMessage[],
getMessageKey: (message: UiMessage) => string,
isUnlockingMessage?: boolean, isUnlockingMessage?: boolean,
unlockingMessageId?: string | null, unlockingMessageId?: string | null,
onUnlockPrivateMessage?: (messageId: string) => void, onUnlockPrivateMessage?: (messageId: string) => void,
onUnlockVoiceMessage?: (messageId: string) => void, onUnlockVoiceMessage?: (messageId: string) => void,
) { ) {
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = []; return buildChatRenderItems(messages, getMessageKey).map((item) =>
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) =>
item.type === "date" ? ( item.type === "date" ? (
<DateHeader key={item.key} date={item.date} /> <DateHeader key={item.key} date={item.date} />
) : ( ) : (
@@ -261,3 +258,41 @@ function renderMessagesWithDateHeaders(
), ),
); );
} }
export function createChatMessageKeyResolver(): ChatMessageKeyResolver {
const localMessageKeys = new WeakMap<UiMessage, string>();
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;
}
@@ -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 (
<>
<HistoryUnlockDialog
open={chatState.unlockHistoryPromptVisible}
lockedCount={chatState.lockedHistoryCount}
isLoading={chatState.isUnlockingHistory}
errorMessage={chatState.unlockHistoryError}
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
/>
<InsufficientCreditsDialog
open={unlockPaywallRequest !== null}
creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0}
shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0}
onClose={onCloseInsufficientCreditsDialog}
onConfirm={onConfirmInsufficientCreditsDialog}
/>
</>
);
}
+1
View File
@@ -10,6 +10,7 @@ export * from "./chat-insufficient-credits-banner";
export * from "./chat-input-bar"; export * from "./chat-input-bar";
export * from "./chat-input-text-field"; export * from "./chat-input-text-field";
export * from "./chat-send-button"; export * from "./chat-send-button";
export * from "./chat-unlock-dialogs";
export * from "./date-header"; export * from "./date-header";
export * from "./external-browser-dialog"; export * from "./external-browser-dialog";
export * from "./first-recharge-offer-banner"; export * from "./first-recharge-offer-banner";
+4 -4
View File
@@ -55,7 +55,7 @@ export function MessageBubble({
aria-label="AI message" aria-label="AI message"
> >
<MessageAvatar isFromAI={true} /> <MessageAvatar isFromAI={true} />
<div style={{ width: 8 }} /> <div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageContent <MessageContent
content={content} content={content}
imageUrl={imageUrl} imageUrl={imageUrl}
@@ -71,7 +71,7 @@ export function MessageBubble({
onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage} onUnlockVoiceMessage={onUnlockVoiceMessage}
/> />
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */} <div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
</div> </div>
); );
} }
@@ -82,7 +82,7 @@ export function MessageBubble({
data-chat-message-id={messageId} data-chat-message-id={messageId}
aria-label="User message" aria-label="User message"
> >
<div style={{ width: 43 }} /> {/* 占位 */} <div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
<MessageContent <MessageContent
content={content} content={content}
imageUrl={imageUrl} imageUrl={imageUrl}
@@ -98,7 +98,7 @@ export function MessageBubble({
onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockVoiceMessage={onUnlockVoiceMessage} onUnlockVoiceMessage={onUnlockVoiceMessage}
/> />
<div style={{ width: 8 }} /> <div className={styles.bubbleInlineSpacer} aria-hidden="true" />
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} /> <MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
</div> </div>
); );
+5 -10
View File
@@ -3,6 +3,7 @@ import { ImageBubble } from "./image-bubble";
import { PrivateMessageCard } from "./private-message-card"; import { PrivateMessageCard } from "./private-message-card";
import { TextBubble } from "./text-bubble"; import { TextBubble } from "./text-bubble";
import { VoiceBubble } from "./voice-bubble"; import { VoiceBubble } from "./voice-bubble";
import styles from "./chat-area.module.css";
export interface MessageContentProps { export interface MessageContentProps {
content: string; content: string;
@@ -55,16 +56,10 @@ export function MessageContent({
return ( return (
<div <div
style={{ className={[
display: "flex", styles.messageContent,
flexDirection: "column", isFromAI ? styles.messageContentAi : styles.messageContentUser,
// 关键:在 row flex 父中撑开(不依赖 content 循环)—— 让 TextBubble 能拿到真实宽度 ].join(" ")}
flex: "1 1 0",
// 防超长 content(图片 / 超长 url)撑爆父
minWidth: 0,
alignItems: isFromAI ? "flex-start" : "flex-end",
gap: 8,
}}
> >
{isLockedPrivateMessage ? ( {isLockedPrivateMessage ? (
<PrivateMessageCard <PrivateMessageCard
@@ -0,0 +1,63 @@
"use client";
import { type Dispatch, useEffect, useRef } from "react";
import type { LoginStatus } from "@/data/dto/auth";
import type { AuthEvent } from "@/stores/auth/auth-events";
export interface GuestLoginState {
hasInitialized: boolean;
isLoading: boolean;
loginStatus: LoginStatus;
}
export interface ShouldSubmitGuestLoginInput extends GuestLoginState {
alreadyRequested: boolean;
}
export interface UseChatGuestLoginInput extends GuestLoginState {
dispatch: Dispatch<AuthEvent>;
}
export function shouldSubmitGuestLogin({
alreadyRequested,
hasInitialized,
isLoading,
loginStatus,
}: ShouldSubmitGuestLoginInput): boolean {
if (!hasInitialized || isLoading) return false;
if (loginStatus !== "notLoggedIn") return false;
return !alreadyRequested;
}
export function useChatGuestLogin({
dispatch,
hasInitialized,
isLoading,
loginStatus,
}: UseChatGuestLoginInput): void {
const guestLoginRequestedRef = useRef(false);
useEffect(() => {
if (!hasInitialized || isLoading) return;
if (loginStatus !== "notLoggedIn") {
guestLoginRequestedRef.current = false;
return;
}
if (
!shouldSubmitGuestLogin({
alreadyRequested: guestLoginRequestedRef.current,
hasInitialized,
isLoading,
loginStatus,
})
) {
return;
}
guestLoginRequestedRef.current = true;
dispatch({ type: "AuthGuestLoginSubmitted" });
}, [dispatch, hasInitialized, isLoading, loginStatus]);
}
@@ -0,0 +1,57 @@
"use client";
import type { LoginStatus } from "@/data/dto/auth";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import type { ChatUpgradeReason } from "@/stores/chat/chat-state";
import { useUserState } from "@/stores/user/user-context";
import {
getInsufficientCreditsMessageLimitView,
getInsufficientCreditsSubscriptionType,
shouldShowMessageLimitBanner,
type InsufficientCreditsMessageLimitView,
} from "../chat-screen.helpers";
export interface UseChatMessageLimitBannerInput {
upgradePromptVisible: boolean;
upgradeReason: ChatUpgradeReason | null;
loginStatus: LoginStatus;
}
export interface ChatMessageLimitBannerView
extends InsufficientCreditsMessageLimitView {
visible: boolean;
unlock: () => void;
}
export function useChatMessageLimitBanner({
upgradePromptVisible,
upgradeReason,
loginStatus,
}: UseChatMessageLimitBannerInput): ChatMessageLimitBannerView {
const navigator = useAppNavigator();
const userState = useUserState();
const view = getInsufficientCreditsMessageLimitView(loginStatus);
function unlock(): void {
if (view.action === "auth") {
navigator.openAuth(ROUTES.chat);
return;
}
navigator.openSubscription({
type: getInsufficientCreditsSubscriptionType(userState.isVip),
returnTo: "chat",
});
}
return {
...view,
visible: shouldShowMessageLimitBanner({
upgradePromptVisible,
upgradeReason,
}),
unlock,
};
}
@@ -0,0 +1,82 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
openChatInExternalBrowser,
recordExternalBrowserPromptShown,
resolveExternalBrowserPromptEligibility,
} from "@/lib/chat/chat_external_browser";
import {
type ExternalBrowserPromptState,
shouldStartExternalBrowserPrompt,
} from "../chat-screen.helpers";
const EXTERNAL_BROWSER_PROMPT_DELAY_MS = 3000;
export interface ExternalBrowserPromptController {
open: boolean;
close: () => void;
confirm: () => Promise<void>;
}
export function useExternalBrowserPrompt({
hasInitialized,
isLoading,
loginStatus,
}: ExternalBrowserPromptState): ExternalBrowserPromptController {
const [open, setOpen] = useState(false);
const promptShownRef = useRef(false);
useEffect(() => {
if (
!shouldStartExternalBrowserPrompt({
hasInitialized,
isLoading,
loginStatus,
})
) {
return;
}
let mounted = true;
let timer: number | undefined;
const init = async () => {
if (promptShownRef.current) return;
const eligibility = await resolveExternalBrowserPromptEligibility();
if (!mounted || !eligibility.canShow) return;
promptShownRef.current = true;
timer = window.setTimeout(() => {
if (!mounted) return;
setOpen(true);
void recordExternalBrowserPromptShown(eligibility.today);
}, EXTERNAL_BROWSER_PROMPT_DELAY_MS);
};
void init();
return () => {
mounted = false;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [hasInitialized, isLoading, loginStatus]);
const close = useCallback(() => {
setOpen(false);
}, []);
const confirm = useCallback(async () => {
setOpen(false);
await openChatInExternalBrowser();
}, []);
return {
open,
close,
confirm,
};
}
@@ -3,12 +3,11 @@
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { ROUTE_BUILDERS } from "@/router/routes"; import { ROUTE_BUILDERS } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; import { useChatState } from "@/stores/chat/chat-context";
import { import {
ChatUnlockDialogs,
FullscreenImageViewer, FullscreenImageViewer,
HistoryUnlockDialog,
InsufficientCreditsDialog,
} from "../../components"; } from "../../components";
import { useChatUnlockNavigationFlow } from "../../hooks/use-chat-unlock-navigation-flow"; import { useChatUnlockNavigationFlow } from "../../hooks/use-chat-unlock-navigation-flow";
import styles from "./chat-image-viewer-screen.module.css"; import styles from "./chat-image-viewer-screen.module.css";
@@ -22,7 +21,6 @@ export function ChatImageViewerScreen({
}: ChatImageViewerScreenProps) { }: ChatImageViewerScreenProps) {
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const chatState = useChatState(); const chatState = useChatState();
const chatDispatch = useChatDispatch();
const returnUrl = ROUTE_BUILDERS.chatImage(messageId); const returnUrl = ROUTE_BUILDERS.chatImage(messageId);
const { const {
unlockPaywallRequest, unlockPaywallRequest,
@@ -46,25 +44,12 @@ export function ChatImageViewerScreen({
requestMessageUnlock(messageId, "image"); requestMessageUnlock(messageId, "image");
}; };
const historyUnlockDialog = ( const unlockDialogs = (
<> <ChatUnlockDialogs
<HistoryUnlockDialog unlockPaywallRequest={unlockPaywallRequest}
open={chatState.unlockHistoryPromptVisible} onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
lockedCount={chatState.lockedHistoryCount} onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
isLoading={chatState.isUnlockingHistory} />
errorMessage={chatState.unlockHistoryError}
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
/>
<InsufficientCreditsDialog
open={unlockPaywallRequest !== null}
creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0}
shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0}
onClose={closeInsufficientCreditsDialog}
onConfirm={confirmInsufficientCreditsDialog}
/>
</>
); );
if (!message) { if (!message) {
@@ -84,7 +69,7 @@ export function ChatImageViewerScreen({
</p> </p>
</main> </main>
</MobileShell> </MobileShell>
{historyUnlockDialog} {unlockDialogs}
</> </>
); );
} }
@@ -104,7 +89,7 @@ export function ChatImageViewerScreen({
onClose={handleClose} onClose={handleClose}
/> />
</MobileShell> </MobileShell>
{historyUnlockDialog} {unlockDialogs}
</> </>
); );
} }