501 lines
15 KiB
TypeScript
501 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, 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 { buildGlobalPageUrl } from "@/router/global-route-context";
|
|
import { resolveAuthenticatedNavigation } from "@/router/navigation-resolver";
|
|
import { getCharacterRoutes, ROUTE_BUILDERS, ROUTES } from "@/router/routes";
|
|
import type { ChatAction, CommercialAction } from "@/data/schemas/chat";
|
|
import { paymentApi } from "@/data/services/api";
|
|
import { behaviorAnalytics } from "@/lib/analytics";
|
|
import {
|
|
recordChatActionEvent,
|
|
rememberChatActionArrival,
|
|
} from "@/lib/chat/chat_action_events";
|
|
|
|
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,
|
|
} 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";
|
|
import { resolveChatActionTarget } from "./chat-action-navigation";
|
|
|
|
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 profileUrl = buildGlobalPageUrl(
|
|
ROUTES.profile,
|
|
characterRoutes.chat,
|
|
);
|
|
const feedbackUrl = buildGlobalPageUrl(
|
|
ROUTES.feedback,
|
|
characterRoutes.chat,
|
|
);
|
|
const coinsRulesUrl = buildGlobalPageUrl(
|
|
ROUTES.coinsRules,
|
|
characterRoutes.chat,
|
|
);
|
|
const searchParams = useSearchParams();
|
|
const state = useChatState();
|
|
const chatDispatch = useChatDispatch();
|
|
const authState = useAuthState();
|
|
const authDispatch = useAuthDispatch();
|
|
const imageOpenedFromChatRef = useRef(false);
|
|
const imageViewerClosingRef = useRef(false);
|
|
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,
|
|
paymentGuidance: state.paymentGuidance,
|
|
});
|
|
const firstRechargeOfferBanner = useFirstRechargeOfferBanner({
|
|
historyLoaded: state.historyLoaded,
|
|
loginStatus: authState.loginStatus,
|
|
});
|
|
const shouldShowPwaInstall =
|
|
state.historyLoaded && state.historyMessages.length >= 10;
|
|
useChatGuestLogin({
|
|
dispatch: authDispatch,
|
|
hasInitialized: authState.hasInitialized,
|
|
isLoading: authState.isLoading,
|
|
loginStatus: authState.loginStatus,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!imageMessageId) {
|
|
imageOpenedFromChatRef.current = false;
|
|
imageViewerClosingRef.current = false;
|
|
}
|
|
}, [imageMessageId]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!imageMessageId ||
|
|
!state.networkHistoryLoaded ||
|
|
selectedImageMessage
|
|
) {
|
|
return;
|
|
}
|
|
router.replace(
|
|
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
|
{ scroll: false },
|
|
);
|
|
}, [
|
|
characterRoutes.chat,
|
|
imageMessageId,
|
|
router,
|
|
searchParams,
|
|
selectedImageMessage,
|
|
state.networkHistoryLoaded,
|
|
]);
|
|
|
|
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 {
|
|
imageOpenedFromChatRef.current = true;
|
|
router.push(
|
|
buildChatImageOverlayUrl(displayMessageId, characterRoutes.chat),
|
|
{ scroll: false },
|
|
);
|
|
}
|
|
|
|
function handleCloseImageViewer(): void {
|
|
if (imageViewerClosingRef.current) return;
|
|
imageViewerClosingRef.current = true;
|
|
if (imageOpenedFromChatRef.current) {
|
|
imageOpenedFromChatRef.current = false;
|
|
router.back();
|
|
return;
|
|
}
|
|
router.replace(
|
|
buildChatWithoutImageOverlayUrl(searchParams, characterRoutes.chat),
|
|
{ scroll: false },
|
|
);
|
|
}
|
|
|
|
function handleUnlockImagePaywall(): void {
|
|
if (!selectedImageMessage) return;
|
|
unlockCoordinator.requestMessageUnlock({
|
|
displayMessageId: selectedImageMessage.displayId,
|
|
remoteMessageId: selectedImageMessage.remoteId,
|
|
kind: "image",
|
|
});
|
|
}
|
|
|
|
function handleOpenUserProfile(): void {
|
|
router.push(
|
|
resolveAuthenticatedNavigation({
|
|
loginStatus: authState.loginStatus,
|
|
targetUrl: profileUrl,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function handleOpenCharacterPrivateZone(): void {
|
|
router.push(characterRoutes.privateZone);
|
|
}
|
|
|
|
async function handleCommercialAction(action: CommercialAction): Promise<void> {
|
|
behaviorAnalytics.elementClick(
|
|
"chat.commercial_action.open",
|
|
action.ctaLabel,
|
|
{
|
|
actionId: action.actionId,
|
|
actionType: action.type,
|
|
characterId: state.characterId,
|
|
ruleId: action.ruleId,
|
|
target: action.target,
|
|
},
|
|
);
|
|
if (action.target === "discountConsent") {
|
|
const offer = await paymentApi.acceptCommercialOffer(action.actionId);
|
|
behaviorAnalytics.elementClick(
|
|
"chat.commercial_action.accepted",
|
|
action.ctaLabel,
|
|
{
|
|
actionId: action.actionId,
|
|
actionType: action.type,
|
|
characterId: state.characterId,
|
|
planId: offer.planId,
|
|
discountPercent: offer.discountPercent,
|
|
},
|
|
);
|
|
router.push(
|
|
ROUTE_BUILDERS.subscription(offer.subscriptionType, {
|
|
sourceCharacterSlug: character.slug,
|
|
returnTo: "chat",
|
|
planId: offer.planId,
|
|
commercialOfferId: offer.commercialOfferId,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
router.push(
|
|
action.target === "giftCatalog"
|
|
? characterRoutes.tip
|
|
: characterRoutes.privateZone,
|
|
);
|
|
}
|
|
|
|
function handleCommercialActionDismiss(action: CommercialAction): void {
|
|
behaviorAnalytics.elementClick(
|
|
"chat.commercial_action.dismiss",
|
|
"Dismiss commercial action",
|
|
{
|
|
actionId: action.actionId,
|
|
actionType: action.type,
|
|
characterId: state.characterId,
|
|
ruleId: action.ruleId,
|
|
},
|
|
);
|
|
}
|
|
|
|
function handleChatActionViewed(action: ChatAction): void {
|
|
void recordChatActionEvent(action, state.characterId, "viewed").catch(
|
|
() => undefined,
|
|
);
|
|
}
|
|
|
|
async function handleChatAction(action: ChatAction): Promise<void> {
|
|
behaviorAnalytics.elementClick(
|
|
"chat.action.open",
|
|
action.ctaLabel,
|
|
{
|
|
actionId: action.actionId,
|
|
actionKind: action.kind,
|
|
actionType: action.type,
|
|
characterId: state.characterId,
|
|
ruleId: action.ruleId,
|
|
},
|
|
);
|
|
await recordChatActionEvent(
|
|
action,
|
|
state.characterId,
|
|
"opened",
|
|
).catch(() => undefined);
|
|
|
|
const targetUrl = await resolveChatActionTarget(action, {
|
|
characterRoutes,
|
|
characterSlug: character.slug,
|
|
profileUrl,
|
|
feedbackUrl,
|
|
coinsRulesUrl,
|
|
getOrderStatus: (orderId) => paymentApi.getOrderStatus(orderId),
|
|
});
|
|
const authenticatedTarget = resolveAuthenticatedNavigation({
|
|
loginStatus: authState.loginStatus,
|
|
targetUrl,
|
|
});
|
|
rememberChatActionArrival(
|
|
action,
|
|
state.characterId,
|
|
targetUrl,
|
|
);
|
|
router.push(authenticatedTarget);
|
|
}
|
|
|
|
function handleChatActionDismiss(action: ChatAction): void {
|
|
behaviorAnalytics.elementClick(
|
|
"chat.action.dismiss",
|
|
"Dismiss chat action",
|
|
{
|
|
actionId: action.actionId,
|
|
actionKind: action.kind,
|
|
actionType: action.type,
|
|
characterId: state.characterId,
|
|
ruleId: action.ruleId,
|
|
},
|
|
);
|
|
void recordChatActionEvent(
|
|
action,
|
|
state.characterId,
|
|
"dismissed",
|
|
).catch(() => undefined);
|
|
}
|
|
|
|
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}
|
|
offerBanner={
|
|
<FirstRechargeOfferBanner
|
|
visible={firstRechargeOfferBanner.visible}
|
|
discountPercent={firstRechargeOfferBanner.discountPercent}
|
|
onClick={firstRechargeOfferBanner.claim}
|
|
onClose={firstRechargeOfferBanner.close}
|
|
variant="compact"
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<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}
|
|
onUserAvatarClick={handleOpenUserProfile}
|
|
onCharacterAvatarClick={handleOpenCharacterPrivateZone}
|
|
onCommercialAction={handleCommercialAction}
|
|
onCommercialActionDismiss={handleCommercialActionDismiss}
|
|
onChatActionViewed={handleChatActionViewed}
|
|
onChatAction={handleChatAction}
|
|
onChatActionDismiss={handleChatActionDismiss}
|
|
onLoadMoreHistory={() => {
|
|
chatDispatch({ type: "ChatLoadMoreHistoryRequested" });
|
|
}}
|
|
/>
|
|
|
|
{messageLimitBanner.visible ? (
|
|
<ChatInsufficientCreditsBanner
|
|
title={messageLimitBanner.title}
|
|
description={messageLimitBanner.description}
|
|
ctaLabel={messageLimitBanner.ctaLabel}
|
|
guidance={messageLimitBanner.guidance}
|
|
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>
|
|
);
|
|
}
|