325 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|