201 lines
6.2 KiB
TypeScript
201 lines
6.2 KiB
TypeScript
"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 安装提示触发器(无可见 UI,3.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>
|
||
);
|
||
}
|