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

253 lines
8.3 KiB
TypeScript

"use client";
import { useEffect, useMemo, type CSSProperties } from "react";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import {
useChatDispatch,
useChatState,
} from "@/stores/chat/chat-context";
import {
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import { MobileShell } from "@/app/_components/core";
import {
ChatArea,
ChatHeader,
ChatInputBar,
ChatInsufficientCreditsBanner,
ChatUnlockDialogs,
FirstRechargeOfferBanner,
FullscreenImageViewer,
PwaInstallOverlay,
} from "./components";
import {
buildChatImageOverlayUrl,
buildChatWithoutImageOverlayUrl,
getChatImageOverlayMessageId,
} from "./chat-image-overlay-url";
import {
deriveIsGuest,
shouldStartExternalBrowserPrompt,
} from "./chat-screen.helpers";
import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner";
import { useChatUnlockCoordinator } from "./hooks/use-chat-unlock-coordinator";
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
import { useSplashLatestMessageSync } from "./hooks/use-splash-latest-message-sync";
const chatShellStyle = {
"--chat-message-font-size": "clamp(16px, 3.333vw, 18px)",
"--chat-message-line-height": "1.5",
} as CSSProperties;
export function ChatScreen() {
const router = useRouter();
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes();
const searchParams = useSearchParams();
const state = useChatState();
const chatDispatch = useChatDispatch();
const authState = useAuthState();
const authDispatch = useAuthDispatch();
useSplashLatestMessageSync({
characterId: state.characterId,
historyLoaded: state.historyLoaded,
loginStatus: authState.loginStatus,
messages: state.historyMessages,
});
const isPromotionBootstrapReady = useChatPromotionBootstrap(
state.characterId,
);
const visibleMessages = isPromotionBootstrapReady
? state.messages
: state.historyMessages;
const imageMessageId = getChatImageOverlayMessageId(searchParams);
const imageReturnUrl = imageMessageId
? buildChatImageOverlayUrl(imageMessageId, characterRoutes.chat)
: characterRoutes.chat;
const unlockCoordinator = useChatUnlockCoordinator({
defaultReturnUrl: characterRoutes.chat,
imageMessageId,
imageReturnUrl,
promotion: state.promotion,
enabled: isPromotionBootstrapReady,
});
const selectedImageMessage = useMemo(
() =>
imageMessageId
? visibleMessages.find(
(item) => item.id === imageMessageId && item.imageUrl,
) ?? null
: null,
[imageMessageId, visibleMessages],
);
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus);
// 发送能力由后端统一返回,当前只处理积分不足引导。
const messageLimitBanner = useChatMessageLimitBanner({
upgradePromptVisible: state.upgradePromptVisible,
upgradeReason: state.upgradeReason,
});
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
historyLoaded: state.historyLoaded,
loginStatus: authState.loginStatus,
});
const shouldShowPwaInstall =
state.historyLoaded && state.historyMessages.length >= 10;
const shouldShowBrowserHint = shouldStartExternalBrowserPrompt({
hasInitialized: authState.hasInitialized,
isLoading: authState.isLoading,
loginStatus: authState.loginStatus,
});
useChatGuestLogin({
dispatch: authDispatch,
hasInitialized: authState.hasInitialized,
isLoading: authState.isLoading,
loginStatus: authState.loginStatus,
});
useEffect(() => {
if (!imageMessageId || !state.historyLoaded || selectedImageMessage) {
return;
}
router.replace(
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
{ scroll: false },
);
}, [
characterRoutes.chat,
imageMessageId,
router,
searchParams,
selectedImageMessage,
state.historyLoaded,
]);
function handleUnlockPrivateMessage(messageId: string): void {
unlockCoordinator.requestMessageUnlock(messageId, "private");
}
function handleUnlockVoiceMessage(messageId: string): void {
unlockCoordinator.requestMessageUnlock(messageId, "voice");
}
function handleUnlockImageMessage(messageId: string): void {
unlockCoordinator.requestMessageUnlock(messageId, "image");
}
function handleOpenImage(messageId: string): void {
router.push(buildChatImageOverlayUrl(messageId, characterRoutes.chat), {
scroll: false,
});
}
function handleCloseImageViewer(): void {
router.replace(
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
{ scroll: false },
);
}
function handleUnlockImagePaywall(): void {
if (!imageMessageId) return;
unlockCoordinator.requestMessageUnlock(imageMessageId, "image");
}
return (
<MobileShell>
<div
className="relative flex h-(--app-viewport-height,100dvh) flex-col overflow-hidden bg-(--color-dark-background,#111) text-(--color-text-primary,#fff)"
style={chatShellStyle}
>
<div className="pointer-events-none absolute inset-0 z-0">
<Image
src={character.assets.chatBackground}
alt=""
fill
priority
sizes="(max-width: 540px) 100vw, 540px"
/>
</div>
<div className="relative z-1 flex min-h-0 flex-[1_1_auto] flex-col">
{/* isGuest 派生自 auth.loginStatus */}
<ChatHeader
isGuest={isGuest}
showBrowserHint={shouldShowBrowserHint}
offerBanner={
<FirstRechargeOfferBanner
visible={firstRechargeOfferBanner.visible}
discountPercent={firstRechargeOfferBanner.discountPercent}
onClick={firstRechargeOfferBanner.claim}
onClose={firstRechargeOfferBanner.close}
/>
}
/>
<ChatArea
characterId={state.characterId}
messages={visibleMessages}
isReplyingAI={state.isReplyingAI}
scrollToBottomSignal={state.outgoingMessageRevision}
initialScrollReady={
state.historyLoaded && isPromotionBootstrapReady
}
canLoadMoreHistory={state.hasMoreHistory}
isLoadingMoreHistory={state.isLoadingMoreHistory}
isUnlockingMessage={state.isUnlockingMessage}
unlockingMessageId={state.unlockingMessageId}
onUnlockPrivateMessage={handleUnlockPrivateMessage}
onUnlockVoiceMessage={handleUnlockVoiceMessage}
onUnlockImageMessage={handleUnlockImageMessage}
onOpenImage={handleOpenImage}
onLoadMoreHistory={() => {
chatDispatch({ type: "ChatLoadMoreHistoryRequested" });
}}
/>
{messageLimitBanner.visible ? (
<ChatInsufficientCreditsBanner
title={messageLimitBanner.title}
description={messageLimitBanner.description}
ctaLabel={messageLimitBanner.ctaLabel}
onUnlock={messageLimitBanner.unlock}
/>
) : (
<ChatInputBar disabled={!state.historyLoaded} />
)}
</div>
{/* PWA 安装提示触发器(聊天消息数量达到阈值后才允许弹出) */}
<PwaInstallOverlay enabled={shouldShowPwaInstall} />
<ChatUnlockDialogs model={unlockCoordinator.dialogs} />
{selectedImageMessage?.imageUrl ? (
<FullscreenImageViewer
characterId={state.characterId}
messageId={selectedImageMessage.id}
imageUrl={selectedImageMessage.imageUrl}
imagePaywalled={selectedImageMessage.imagePaywalled === true}
isUnlockingImagePaywall={
state.isUnlockingMessage &&
state.unlockingMessageId === selectedImageMessage.id
}
onUnlockImagePaywall={handleUnlockImagePaywall}
onClose={handleCloseImageViewer}
/>
) : null}
</div>
</MobileShell>
);
}