214 lines
6.7 KiB
TypeScript
214 lines
6.7 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 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 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */}
|
||
<PwaInstallOverlay />
|
||
|
||
<ExternalBrowserDialog
|
||
open={showExternalBrowserDialog}
|
||
onClose={() => setShowExternalBrowserDialog(false)}
|
||
onConfirm={() => void handleOpenExternalBrowser()}
|
||
/>
|
||
</div>
|
||
</MobileShell>
|
||
);
|
||
}
|