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

214 lines
6.7 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 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 { useChatDispatch, 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 chatDispatch = useChatDispatch();
const authState = useAuthState();
const router = useRouter();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false);
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus);
// 消息数量限制由后端统一返回 blocked,前端不再处理本地额度。
const showMessageLimitBanner =
state.paywallTriggered &&
(state.paywallReason === "daily_limit" ||
state.paywallReason === "total_limit");
const messageLimitTitle =
state.paywallReason === "total_limit"
? "The limit for free chat times\nhas been reached"
: undefined;
const messageLimitCta = isGuest ? "Log in to continue chatting" : undefined;
const showPhotoPaywallBanner =
state.paywallTriggered && state.paywallReason === "photo_paywall";
const showPrivatePaywallBanner =
state.paywallTriggered && state.paywallReason === "private_paywall";
const showInlineUpgradeBanner =
showPhotoPaywallBanner || showPrivatePaywallBanner;
const inlineUpgradeTitle = showPrivatePaywallBanner
? "Unlock VIP to view private messages"
: "Unlock VIP to view Elio's photos";
const inlineUpgradeCta = showPrivatePaywallBanner
? "Activate VIP to view private messages"
: "Activate VIP to view photos";
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);
}
function handleUnlockPrivateMessage(messageId: string): void {
chatDispatch({ type: "ChatUnlockPrivateMessage", messageId });
}
function handleMessageLimitUnlock(): void {
if (isGuest) {
router.push(ROUTES.auth);
return;
}
router.push(`${ROUTES.subscription}?type=vip`);
}
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}
unlockingPrivateMessageId={state.unlockingPrivateMessageId}
onUnlockPrivateMessage={handleUnlockPrivateMessage}
/>
{showInlineUpgradeBanner ? (
<ChatQuotaExhaustedBanner
title={inlineUpgradeTitle}
ctaLabel={inlineUpgradeCta}
/>
) : null}
{showMessageLimitBanner ? (
<ChatQuotaExhaustedBanner
title={messageLimitTitle}
ctaLabel={messageLimitCta}
onUnlock={handleMessageLimitUnlock}
/>
) : (
<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>
);
}