Files
cozsweet-frontend-nextjs/src/app/chat/chat-screen.tsx
T

201 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { useUserState } from "@/stores/user/user-context";
import { ROUTES } from "@/router/routes";
import {
openChatInExternalBrowser,
recordExternalBrowserPromptShown,
resolveExternalBrowserPromptEligibility,
} from "@/lib/chat/chat_external_browser";
import { MobileShell } from "@/app/_components/core";
import {
BrowserHintOverlay,
ChatArea,
ChatHeader,
ChatInputBar,
ChatInsufficientCreditsBanner,
ExternalBrowserDialog,
HistoryUnlockDialog,
InsufficientCreditsDialog,
PwaInstallOverlay,
} from "./components";
import {
deriveIsGuest,
getChatPaywallNavigationUrl,
getInsufficientCreditsSubscriptionType,
isChatDevelopmentEnvironment,
shouldStartExternalBrowserPrompt,
} from "./chat-screen.helpers";
import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-flow";
import styles from "./components/chat-screen.module.css";
export function ChatScreen() {
const state = useChatState();
const chatDispatch = useChatDispatch();
const authState = useAuthState();
const userState = useUserState();
const router = useRouter();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false);
const {
unlockPaywallRequest,
requestMessageUnlock,
closeInsufficientCreditsDialog,
confirmInsufficientCreditsDialog,
} = useChatUnlockNavigationFlow({ returnUrl: ROUTES.chat });
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus);
// 发送能力由后端统一返回,当前只处理积分不足引导。
const showMessageLimitBanner =
state.upgradePromptVisible &&
state.upgradeReason === "insufficient_credits";
const messageLimitTitle =
"Insufficient credits\nTop up to continue chatting";
const messageLimitCtaLabel = "Top up credits to continue";
const externalBrowserPromptShownRef = useRef(false);
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 {
requestMessageUnlock(messageId, "private");
}
function handleMessageLimitUnlock(): void {
router.push(
getChatPaywallNavigationUrl(
authState.loginStatus,
getInsufficientCreditsSubscriptionType(userState.isVip),
),
);
}
function handleUnlockVoiceMessage(messageId: string): void {
requestMessageUnlock(messageId, "voice");
}
return (
<MobileShell>
<div className={styles.shell}>
<div className={styles.background}>
<Image
src="/images/chat/bg-chatpage.png"
alt=""
fill
priority
sizes="(max-width: 500px) 100vw, 500px"
/>
</div>
<div className={styles.layout}>
{/* isGuest 派生自 auth.loginStatus */}
<ChatHeader isGuest={isGuest} />
<ChatArea
messages={state.messages}
isReplyingAI={state.isReplyingAI}
isGuest={isGuest}
isUnlockingMessage={state.isUnlockingMessage}
unlockingMessageId={state.unlockingMessageId}
onUnlockPrivateMessage={handleUnlockPrivateMessage}
onUnlockVoiceMessage={handleUnlockVoiceMessage}
/>
{showMessageLimitBanner ? (
<ChatInsufficientCreditsBanner
title={messageLimitTitle}
ctaLabel={messageLimitCtaLabel}
onUnlock={handleMessageLimitUnlock}
/>
) : (
<ChatInputBar disabled={!state.historyLoaded} />
)}
</div>
{/* 浏览器提示(应用内浏览器检测) */}
<BrowserHintOverlay forceShow={isChatDevelopmentEnvironment()} />
{/* PWA 安装提示触发器(无可见 UI3.5s 后弹 dialog */}
<PwaInstallOverlay />
<ExternalBrowserDialog
open={showExternalBrowserDialog}
onClose={() => setShowExternalBrowserDialog(false)}
onConfirm={() => void handleOpenExternalBrowser()}
/>
<HistoryUnlockDialog
open={state.unlockHistoryPromptVisible}
lockedCount={state.lockedHistoryCount}
isLoading={state.isUnlockingHistory}
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>
</MobileShell>
);
}