174 lines
5.4 KiB
TypeScript
174 lines
5.4 KiB
TypeScript
"use client";
|
||
/**
|
||
* ChatScreen 编排层
|
||
*/
|
||
import { useEffect, useRef, useState } from "react";
|
||
import Image from "next/image";
|
||
|
||
import { useAuthState } from "@/stores/auth/auth-context";
|
||
import type { LoginStatus } from "@/data/dto/auth";
|
||
import { AppStorage } from "@/data/storage/app/app_storage";
|
||
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||
import { UserStorage } from "@/data/storage/user/user_storage";
|
||
import { useChatState } from "@/stores/chat/chat-context";
|
||
import { AppEnvUtil, BrowserDetector, todayString, UrlLauncherUtil } from "@/utils";
|
||
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
|
||
|
||
import { MobileShell } from "@/app/_components/core";
|
||
|
||
import {
|
||
BrowserHintOverlay,
|
||
ChatArea,
|
||
ChatHeader,
|
||
ChatInputBar,
|
||
ChatQuotaExhaustedBanner,
|
||
ExternalBrowserDialog,
|
||
PwaInstallOverlay,
|
||
} from "./components";
|
||
import styles from "./components/chat-screen.module.css";
|
||
|
||
|
||
/**
|
||
* 派生 isGuest —— 单一来源:`auth.loginStatus`
|
||
*/
|
||
function deriveIsGuest(loginStatus: LoginStatus): boolean {
|
||
return loginStatus === "guest";
|
||
}
|
||
|
||
export function ChatScreen() {
|
||
const state = useChatState();
|
||
const authState = useAuthState();
|
||
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
|
||
useState(false);
|
||
|
||
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
|
||
const isGuest = deriveIsGuest(authState.loginStatus);
|
||
|
||
// 游客配额耗尽 → 显示 Unlock CTA banner(替代 ChatInputBar)
|
||
// 三重条件:游客 + 配额加载完 + 用户被配额 guard 拦截过至少一次
|
||
// - quotaLoaded 避免一进 /chat 就跳 banner(loadQuota 还在 in-flight)
|
||
// - quotaExceededTrigger 而非 guestTotalQuota / guestRemainingQuota ——
|
||
// trigger 由 chat 机器的 guard 在 ChatSendMessage / ChatSendImage 被拦截时 +1,
|
||
// 是 "用户被配额拒绝过" 的权威信号(避免依赖 quota 数值本身的边界判断)
|
||
const showExhaustedBanner =
|
||
isGuest && state.quotaLoaded && state.quotaExceededTrigger > 0;
|
||
|
||
const externalBrowserPromptShownRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
if (!authState.hasInitialized || authState.isLoading) return;
|
||
const isDev = AppEnvUtil.isDevelopment();
|
||
const canTriggerPrompt =
|
||
isDev ||
|
||
(authState.loginStatus === "facebook" && BrowserDetector.isInAppBrowser());
|
||
if (!canTriggerPrompt) return;
|
||
|
||
let mounted = true;
|
||
let timer: number | undefined;
|
||
|
||
const init = async () => {
|
||
if (externalBrowserPromptShownRef.current) return;
|
||
|
||
const today = todayString();
|
||
const canShowResult = await AppStorage.canShowExternalBrowserDialog(today);
|
||
if (!mounted || !canShowResult.success || !canShowResult.data) return;
|
||
|
||
externalBrowserPromptShownRef.current = true;
|
||
timer = window.setTimeout(() => {
|
||
if (!mounted) return;
|
||
setShowExternalBrowserDialog(true);
|
||
void AppStorage.recordExternalBrowserDialogShown(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);
|
||
|
||
const authStorage = AuthStorage.getInstance();
|
||
const userStorage = UserStorage.getInstance();
|
||
const [deviceIdR, facebookIdR, avatarUrlR] = await Promise.all([
|
||
authStorage.getDeviceId(),
|
||
authStorage.getFacebookId(),
|
||
userStorage.getAvatarUrl(),
|
||
]);
|
||
|
||
const deviceId = deviceIdR.success ? deviceIdR.data : null;
|
||
const facebookId = facebookIdR.success ? facebookIdR.data : null;
|
||
const avatarUrl = avatarUrlR.success ? avatarUrlR.data : null;
|
||
|
||
if (deviceId && facebookId) {
|
||
UrlLauncherUtil.openUrlWithExternalBrowser(
|
||
ROUTE_BUILDERS.chatDeviceId(deviceId),
|
||
{
|
||
queryParams: {
|
||
fbid: facebookId,
|
||
...(avatarUrl ? { avatarUrl } : {}),
|
||
},
|
||
},
|
||
);
|
||
return;
|
||
}
|
||
|
||
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash);
|
||
}
|
||
|
||
return (
|
||
<MobileShell>
|
||
<div className={styles.shell}>
|
||
{/* 背景图(与原 Dart MobileLayout 一致) */}
|
||
<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}
|
||
/>
|
||
|
||
{showExhaustedBanner ? (
|
||
<ChatQuotaExhaustedBanner />
|
||
) : (
|
||
<ChatInputBar disabled={state.isReplyingAI} />
|
||
)}
|
||
|
||
</div>
|
||
|
||
{/* 浏览器提示(应用内浏览器检测) */}
|
||
<BrowserHintOverlay forceShow={AppEnvUtil.isDevelopment()} />
|
||
|
||
{/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */}
|
||
<PwaInstallOverlay />
|
||
|
||
<ExternalBrowserDialog
|
||
open={showExternalBrowserDialog}
|
||
onClose={() => setShowExternalBrowserDialog(false)}
|
||
onConfirm={() => void handleOpenExternalBrowser()}
|
||
/>
|
||
</div>
|
||
</MobileShell>
|
||
);
|
||
}
|