"use client"; import { useEffect, useRef } from "react"; import { shallowEqual } from "@xstate/react"; import type { ChatLockType } from "@/data/schemas/chat"; import { behaviorAnalytics } from "@/lib/analytics"; import { consumePendingChatUnlock, peekPendingChatUnlock, type PendingChatPromotion, type PendingChatUnlock, type PendingChatUnlockKind, } from "@/lib/navigation/chat_unlock_session"; import { useAppNavigator } from "@/router/use-app-navigator"; import { useChatDispatch, useChatSelector, } from "@/stores/chat/chat-context"; import type { ChatPromotionState } from "@/stores/chat/helper/promotion"; import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state"; import type { UiMessage } from "@/stores/chat/ui-message"; import { recordChatActionEventById } from "@/lib/chat/chat_action_events"; import { useUserSelector } from "@/stores/user/user-context"; import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers"; export interface ChatUnlockCoordinatorScope { defaultReturnUrl: string; imageMessageId: string | null; imageReturnUrl: string; promotion: ChatPromotionState | null; } export interface CoordinatedMessageUnlockRequest { displayMessageId: string; messageId?: string; kind: PendingChatUnlockKind; lockType?: ChatLockType; clientLockId?: string; promotion?: PendingChatPromotion; returnUrl: string; } export interface ChatUnlockDialogModel { unlockPaywallRequest: ChatUnlockPaywallRequest | null; historyUnlock: { open: boolean; lockedCount: number; isLoading: boolean; errorMessage: string | null; guidance: import("@/data/schemas/chat").PaymentGuidance | null; }; closeHistoryUnlock: () => void; confirmHistoryUnlock: () => void; closePaywall: () => void; confirmPaywall: () => void; } export interface UseChatUnlockCoordinatorInput extends ChatUnlockCoordinatorScope { enabled?: boolean; } export interface UseChatUnlockCoordinatorOutput { requestMessageUnlock: (input: { displayMessageId: string; remoteMessageId?: string; kind: PendingChatUnlockKind; }) => void; dialogs: ChatUnlockDialogModel; } export function useChatUnlockCoordinator({ defaultReturnUrl, imageMessageId, imageReturnUrl, promotion, enabled = true, }: UseChatUnlockCoordinatorInput): UseChatUnlockCoordinatorOutput { const navigator = useAppNavigator(); const isVip = useUserSelector((state) => state.context.isVip); const trackedPaywallRef = useRef(null); const chatState = useChatSelector( (state) => ({ characterId: state.context.characterId, historyLoaded: state.context.historyLoaded, messages: state.context.messages, isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }), lockedHistoryCount: state.context.lockedHistoryCount, unlockHistoryError: state.context.unlockHistoryError, unlockHistoryPaymentGuidance: state.context.unlockHistoryPaymentGuidance, unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible, unlockPaywallRequest: state.context.unlockPaywallRequest, }), shallowEqual, ); const chatDispatch = useChatDispatch(); const unlockPaywallRequest = enabled ? chatState.unlockPaywallRequest : null; useEffect(() => { const guidance = chatState.unlockHistoryPaymentGuidance; if (!enabled || guidance?.characterId !== chatState.characterId) return; void recordChatActionEventById( guidance.guidanceId, chatState.characterId, "viewed", ).catch(() => undefined); }, [ chatState.characterId, chatState.unlockHistoryPaymentGuidance, enabled, ]); useEffect(() => { if (!unlockPaywallRequest) { trackedPaywallRef.current = null; return; } const requestKey = [ unlockPaywallRequest.displayMessageId, unlockPaywallRequest.messageId ?? "", unlockPaywallRequest.kind, unlockPaywallRequest.clientLockId ?? "", ].join(":"); if (trackedPaywallRef.current === requestKey) return; trackedPaywallRef.current = requestKey; behaviorAnalytics.paywallShown( { entryPoint: "chat_unlock", triggerReason: unlockPaywallRequest.promotion ? "ad_landing" : "insufficient_credits", }, { isVip }, ); const guidance = unlockPaywallRequest.paymentGuidance; if (guidance?.characterId === chatState.characterId) { void recordChatActionEventById( guidance.guidanceId, chatState.characterId, "viewed", ).catch(() => undefined); } }, [chatState.characterId, isVip, unlockPaywallRequest]); useEffect(() => { if (!enabled) return; if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return; let cancelled = false; const resumePendingUnlock = async () => { const pending = await peekPendingChatUnlock(chatState.characterId); if ( cancelled || !pending || !shouldResumePendingChatUnlock(pending, { defaultReturnUrl, imageMessageId, imageReturnUrl, }) ) { return; } const consumed = await consumePendingChatUnlock(chatState.characterId); if (cancelled || !consumed) return; const displayMessageId = resolvePendingUnlockDisplayMessageId( consumed, chatState.messages, promotion, ); chatDispatch({ type: "ChatUnlockMessageRequested", displayMessageId, remoteMessageId: consumed.messageId, kind: consumed.kind, lockType: consumed.lockType, clientLockId: consumed.clientLockId, }); }; void resumePendingUnlock(); return () => { cancelled = true; }; }, [ chatDispatch, chatState.characterId, chatState.historyLoaded, defaultReturnUrl, enabled, imageMessageId, imageReturnUrl, navigator.isAuthenticatedUser, promotion, chatState.messages, ]); function requestMessageUnlock(input: { displayMessageId: string; remoteMessageId?: string; kind: PendingChatUnlockKind; }): void { const request = resolveMessageUnlockRequest( input, { defaultReturnUrl, imageMessageId, imageReturnUrl, promotion, }, ); navigator.startMessageUnlock({ ...request, onAuthenticated: () => { chatDispatch({ type: "ChatUnlockMessageRequested", displayMessageId: request.displayMessageId, remoteMessageId: request.messageId, kind: request.kind, lockType: request.lockType, clientLockId: request.clientLockId, }); }, }); } function closeHistoryUnlock(): void { chatDispatch({ type: "ChatUnlockHistoryDismissed" }); } function confirmHistoryUnlock(): void { const guidance = chatState.unlockHistoryPaymentGuidance?.characterId === chatState.characterId ? chatState.unlockHistoryPaymentGuidance : null; if (guidance) { if (guidance.mode !== "guide") return; void recordChatActionEventById( guidance.guidanceId, chatState.characterId, "opened", ).catch(() => undefined); navigator.openSubscription({ type: isVip || !guidance.purchaseOptions.includes("vip") ? "topup" : "vip", returnTo: "chat", chatActionId: guidance.guidanceId, analytics: { entryPoint: "chat_unlock", triggerReason: "insufficient_credits", }, }); return; } chatDispatch({ type: "ChatUnlockHistoryConfirmed" }); } function closePaywall(): void { chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" }); } function confirmPaywall(): void { if (!unlockPaywallRequest) return; const guidance = unlockPaywallRequest.paymentGuidance?.characterId === chatState.characterId ? unlockPaywallRequest.paymentGuidance : null; if (guidance && guidance.mode !== "guide") return; const returnUrl = resolveChatUnlockReturnUrl( unlockPaywallRequest, { defaultReturnUrl, imageMessageId, imageReturnUrl, }, ); chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" }); if (guidance) { void recordChatActionEventById( guidance.guidanceId, chatState.characterId, "opened", ).catch(() => undefined); } navigator.openSubscriptionForPendingUnlock({ displayMessageId: unlockPaywallRequest.displayMessageId, messageId: unlockPaywallRequest.messageId, kind: unlockPaywallRequest.kind, lockType: unlockPaywallRequest.lockType, clientLockId: unlockPaywallRequest.clientLockId, promotion: unlockPaywallRequest.promotion, returnUrl, type: isVip || (guidance && !guidance.purchaseOptions.includes("vip")) ? "topup" : getInsufficientCreditsSubscriptionType(isVip), analytics: { entryPoint: "chat_unlock", triggerReason: unlockPaywallRequest.promotion ? "ad_landing" : "insufficient_credits", }, ...(guidance ? { chatActionId: guidance.guidanceId } : {}), }); } return { requestMessageUnlock, dialogs: { unlockPaywallRequest, historyUnlock: { open: chatState.unlockHistoryPromptVisible, lockedCount: chatState.lockedHistoryCount, isLoading: chatState.isUnlockingHistory, errorMessage: chatState.unlockHistoryError, guidance: chatState.unlockHistoryPaymentGuidance, }, closeHistoryUnlock, confirmHistoryUnlock, closePaywall, confirmPaywall, }, }; } export function resolveMessageUnlockRequest( input: { displayMessageId: string; remoteMessageId?: string; kind: PendingChatUnlockKind; }, scope: ChatUnlockCoordinatorScope, ): CoordinatedMessageUnlockRequest { const matchedPromotion = scope.promotion?.message.displayId === input.displayMessageId ? scope.promotion : null; const remoteMessageId = input.remoteMessageId ?? matchedPromotion?.message.remoteId; const target = { displayMessageId: input.displayMessageId, ...(remoteMessageId ? { messageId: remoteMessageId } : {}), kind: input.kind, ...(matchedPromotion ? { lockType: matchedPromotion.session.lockType, clientLockId: matchedPromotion.session.clientLockId, promotion: matchedPromotion.session, } : {}), }; return { ...target, returnUrl: resolveChatUnlockReturnUrl(target, scope), }; } export function shouldResumePendingChatUnlock( pending: PendingChatUnlock, scope: Omit, ): boolean { if (pending.returnUrl === scope.defaultReturnUrl) return true; if (pending.returnUrl !== scope.imageReturnUrl) return false; return isCurrentImageUnlock(pending, scope.imageMessageId); } export function resolvePendingUnlockDisplayMessageId( pending: PendingChatUnlock, messages: readonly UiMessage[], promotion: ChatPromotionState | null, ): string { if ( promotion && (promotion.message.displayId === pending.displayMessageId || (pending.messageId && promotion.message.remoteId === pending.messageId)) ) { return promotion.message.displayId; } const exactMessage = messages.find( (message) => message.displayId === pending.displayMessageId, ); if (exactMessage) return exactMessage.displayId; if (!pending.messageId) return pending.displayMessageId; const remoteMessage = messages.find( (message) => message.isFromAI && message.locked === true && message.remoteId === pending.messageId, ) ?? messages.find( (message) => message.isFromAI && message.remoteId === pending.messageId, ); return remoteMessage?.displayId ?? pending.displayMessageId; } export function resolveChatUnlockReturnUrl( target: Pick< CoordinatedMessageUnlockRequest, "displayMessageId" | "messageId" | "kind" | "promotion" >, scope: Pick< ChatUnlockCoordinatorScope, "defaultReturnUrl" | "imageMessageId" | "imageReturnUrl" >, ): string { return isCurrentImageUnlock(target, scope.imageMessageId) ? scope.imageReturnUrl : scope.defaultReturnUrl; } function isCurrentImageUnlock( target: Pick< CoordinatedMessageUnlockRequest, "displayMessageId" | "messageId" | "kind" | "promotion" >, imageMessageId: string | null, ): boolean { if (target.promotion || target.kind !== "image" || !imageMessageId) { return false; } return ( target.displayMessageId === imageMessageId || target.messageId === imageMessageId ); }