235 lines
7.4 KiB
TypeScript
235 lines
7.4 KiB
TypeScript
"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 { 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 {
|
|
BrowserHintOverlay,
|
|
ChatArea,
|
|
ChatHeader,
|
|
ChatInputBar,
|
|
ChatInsufficientCreditsBanner,
|
|
ExternalBrowserDialog,
|
|
FirstRechargeOfferBanner,
|
|
HistoryUnlockDialog,
|
|
InsufficientCreditsDialog,
|
|
PwaInstallOverlay,
|
|
} from "./components";
|
|
import {
|
|
deriveIsGuest,
|
|
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 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,
|
|
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 firstRechargeOfferBanner = useFirstRechargeOfferBanner({
|
|
historyLoaded: state.historyLoaded,
|
|
loginStatus: authState.loginStatus,
|
|
});
|
|
const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10;
|
|
|
|
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<void> {
|
|
setShowExternalBrowserDialog(false);
|
|
await openChatInExternalBrowser();
|
|
}
|
|
|
|
function handleUnlockPrivateMessage(messageId: string): void {
|
|
requestMessageUnlock(messageId, "private");
|
|
}
|
|
|
|
function handleMessageLimitUnlock(): void {
|
|
navigator.openSubscription({
|
|
type: getInsufficientCreditsSubscriptionType(userState.isVip),
|
|
returnTo: "chat",
|
|
});
|
|
}
|
|
|
|
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: 540px) 100vw, 540px"
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.layout}>
|
|
{/* isGuest 派生自 auth.loginStatus */}
|
|
<ChatHeader
|
|
isGuest={isGuest}
|
|
offerBanner={
|
|
<FirstRechargeOfferBanner
|
|
visible={firstRechargeOfferBanner.visible}
|
|
discountPercent={firstRechargeOfferBanner.discountPercent}
|
|
onClick={firstRechargeOfferBanner.claim}
|
|
onClose={firstRechargeOfferBanner.close}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<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 安装提示触发器(聊天消息数量达到阈值后才允许弹出) */}
|
|
<PwaInstallOverlay enabled={shouldShowPwaInstall} />
|
|
|
|
<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>
|
|
);
|
|
}
|