"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 { 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 { 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 (
} /> { chatDispatch({ type: "ChatLoadMoreHistoryRequested" }); }} /> {messageLimitBanner.visible ? ( ) : ( )}
{/* PWA 安装提示触发器(聊天消息数量达到阈值后才允许弹出) */} {selectedImageMessage?.imageUrl ? ( ) : null}
); }