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

325 lines
10 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 { useCharacterCatalog } from "@/providers/character-catalog-provider";
import { clearPendingChatNavigation } from "@/lib/navigation/chat_unlock_session";
import { getCharacterRoutes } from "@/router/routes";
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 characterCatalog = useCharacterCatalog();
const refreshCharacterCatalog = characterCatalog.refresh;
const defaultCharacterSlug = characterCatalog.defaultCharacter.slug;
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.displayId === imageMessageId ||
item.remoteId === imageMessageId) &&
item.imageUrl,
) ?? null
: null,
[imageMessageId, visibleMessages],
);
// Guest status belongs to auth state and is not duplicated in the Chat actor.
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,
]);
useEffect(() => {
const errorCode = state.characterErrorCode;
if (errorCode === "CHARACTER_MISMATCH") {
chatDispatch({ type: "ChatHistoryRefreshRequested" });
return;
}
if (
errorCode !== "CHARACTER_DISABLED" &&
errorCode !== "CHARACTER_NOT_FOUND"
) {
return;
}
let cancelled = false;
void (async () => {
if (errorCode === "CHARACTER_NOT_FOUND") {
await clearPendingChatNavigation();
}
await refreshCharacterCatalog();
if (!cancelled) {
router.replace(
getCharacterRoutes(defaultCharacterSlug).splash,
);
}
})();
return () => {
cancelled = true;
};
}, [
chatDispatch,
defaultCharacterSlug,
refreshCharacterCatalog,
router,
state.characterErrorCode,
]);
function handleUnlockPrivateMessage(
displayMessageId: string,
remoteMessageId?: string,
): void {
unlockCoordinator.requestMessageUnlock({
displayMessageId,
remoteMessageId,
kind: "private",
});
}
function handleUnlockVoiceMessage(
displayMessageId: string,
remoteMessageId?: string,
): void {
unlockCoordinator.requestMessageUnlock({
displayMessageId,
remoteMessageId,
kind: "voice",
});
}
function handleUnlockImageMessage(
displayMessageId: string,
remoteMessageId?: string,
): void {
unlockCoordinator.requestMessageUnlock({
displayMessageId,
remoteMessageId,
kind: "image",
});
}
function handleOpenImage(displayMessageId: string): void {
router.push(
buildChatImageOverlayUrl(displayMessageId, characterRoutes.chat),
{ scroll: false },
);
}
function handleCloseImageViewer(): void {
router.replace(
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
{ scroll: false },
);
}
function handleUnlockImagePaywall(): void {
if (!selectedImageMessage) return;
unlockCoordinator.requestMessageUnlock({
displayMessageId: selectedImageMessage.displayId,
remoteMessageId: selectedImageMessage.remoteId,
kind: "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">
<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 || !state.canSendMessage}
/>
)}
</div>
{/* PWA 安装提示触发器(聊天消息数量达到阈值后才允许弹出) */}
<PwaInstallOverlay enabled={shouldShowPwaInstall} />
<ChatUnlockDialogs model={unlockCoordinator.dialogs} />
{selectedImageMessage?.imageUrl ? (
<FullscreenImageViewer
characterId={state.characterId}
remoteMessageId={selectedImageMessage.remoteId}
imageUrl={selectedImageMessage.imageUrl}
imagePaywalled={selectedImageMessage.imagePaywalled === true}
isUnlockingImagePaywall={
state.isUnlockingMessage &&
state.unlockingMessageId === selectedImageMessage.displayId
}
onUnlockImagePaywall={handleUnlockImagePaywall}
onClose={handleCloseImageViewer}
/>
) : null}
</div>
</MobileShell>
);
}