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

191 lines
6.0 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 { 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 就跳 bannerloadQuota 还在 in-flight
// - quotaExceededTrigger 而非 guestTotalQuota / guestRemainingQuota ——
// trigger 由 chat 机器的 guard 在 ChatSendMessage / ChatSendImage 被拦截时 +1
// 是 "用户被配额拒绝过" 的权威信号(避免依赖 quota 数值本身的边界判断)
const showGuestQuotaBanner =
isGuest && state.quotaLoaded && state.quotaExceededTrigger > 0;
const showDailyLimitBanner =
!isGuest &&
state.paywallTriggered &&
state.paywallReason === "daily_limit";
const showPhotoPaywallBanner =
state.paywallTriggered && state.paywallReason === "photo_paywall";
const showExhaustedBanner =
showGuestQuotaBanner || showDailyLimitBanner || showPhotoPaywallBanner;
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
title={
showPhotoPaywallBanner
? "Unlock VIP to view Elio's photos"
: undefined
}
ctaLabel={
showPhotoPaywallBanner
? "Activate VIP to view photos"
: undefined
}
/>
) : (
<ChatInputBar disabled={state.isReplyingAI} />
)}
</div>
{/* 浏览器提示(应用内浏览器检测) */}
<BrowserHintOverlay forceShow={AppEnvUtil.isDevelopment()} />
{/* PWA 安装提示触发器(无可见 UI3.5s 后弹 dialog */}
<PwaInstallOverlay />
<ExternalBrowserDialog
open={showExternalBrowserDialog}
onClose={() => setShowExternalBrowserDialog(false)}
onConfirm={() => void handleOpenExternalBrowser()}
/>
</div>
</MobileShell>
);
}