diff --git a/src/app/chat/__tests__/chat-unlock-coordinator.test.ts b/src/app/chat/__tests__/chat-unlock-coordinator.test.ts new file mode 100644 index 00000000..37f27a01 --- /dev/null +++ b/src/app/chat/__tests__/chat-unlock-coordinator.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; + +import type { + PendingChatPromotion, + PendingChatUnlock, +} from "@/lib/navigation/chat_unlock_session"; +import { createChatPromotionState } from "@/stores/chat/helper/promotion"; + +import { + resolveChatUnlockReturnUrl, + resolveMessageUnlockRequest, + shouldResumePendingChatUnlock, +} from "../hooks/use-chat-unlock-coordinator"; + +const defaultScope = { + defaultReturnUrl: "/chat", + imageMessageId: null, + imageReturnUrl: "/chat", + promotion: null, +}; + +const promotionSession: PendingChatPromotion = { + promotionType: "image", + lockType: "image_paywall", + clientLockId: "promotion-1", + createdAt: 1, +}; + +describe("chat unlock coordinator", () => { + it("creates a regular message unlock request with its remote id", () => { + expect( + resolveMessageUnlockRequest( + { messageId: "message-1", kind: "private" }, + defaultScope, + ), + ).toEqual({ + displayMessageId: "message-1", + messageId: "message-1", + kind: "private", + returnUrl: "/chat", + }); + }); + + it("returns to the active image overlay for its image unlock", () => { + const imageScope = { + ...defaultScope, + imageMessageId: "image-1", + imageReturnUrl: "/chat?image=image-1", + }; + + expect( + resolveMessageUnlockRequest( + { messageId: "image-1", kind: "image" }, + imageScope, + ).returnUrl, + ).toBe("/chat?image=image-1"); + }); + + it("uses the promotion lock identity without sending a temporary remote id", () => { + const promotion = createChatPromotionState(promotionSession); + + expect( + resolveMessageUnlockRequest( + { messageId: "promotion:promotion-1", kind: "image" }, + { ...defaultScope, promotion }, + ), + ).toEqual({ + displayMessageId: "promotion:promotion-1", + kind: "image", + lockType: "image_paywall", + clientLockId: "promotion-1", + promotion: promotionSession, + returnUrl: "/chat", + }); + }); + + it("keeps the backend id after a promotion receives one", () => { + const promotion = createChatPromotionState( + promotionSession, + "promotion-message-1", + ); + + expect( + resolveMessageUnlockRequest( + { messageId: "promotion-message-1", kind: "image" }, + { ...defaultScope, promotion }, + ).messageId, + ).toBe("promotion-message-1"); + }); + + it("resumes pending unlocks for the default chat return", () => { + expect( + shouldResumePendingChatUnlock( + createPendingUnlock({ + displayMessageId: "message-1", + messageId: "message-1", + kind: "private", + returnUrl: "/chat", + }), + defaultScope, + ), + ).toBe(true); + }); + + it("only resumes an image return when its message matches the overlay", () => { + const imageScope = { + defaultReturnUrl: "/chat", + imageMessageId: "image-1", + imageReturnUrl: "/chat?image=image-1", + }; + const pending = createPendingUnlock({ + displayMessageId: "image-1", + messageId: "image-1", + kind: "image", + returnUrl: "/chat?image=image-1", + }); + + expect(shouldResumePendingChatUnlock(pending, imageScope)).toBe(true); + expect( + shouldResumePendingChatUnlock( + { ...pending, displayMessageId: "image-2", messageId: "image-2" }, + imageScope, + ), + ).toBe(false); + }); + + it("never routes a promotion unlock through the image overlay", () => { + const promotion = createChatPromotionState(promotionSession); + const imageMessageId = "promotion:promotion-1"; + const imageScope = { + defaultReturnUrl: "/chat", + imageMessageId, + imageReturnUrl: `/chat?image=${imageMessageId}`, + }; + + expect( + resolveMessageUnlockRequest( + { messageId: imageMessageId, kind: "image" }, + { + ...imageScope, + promotion, + }, + ).returnUrl, + ).toBe("/chat"); + expect( + shouldResumePendingChatUnlock( + { + ...createPendingUnlock({ + displayMessageId: imageMessageId, + kind: "image", + returnUrl: imageScope.imageReturnUrl, + }), + lockType: promotionSession.lockType, + clientLockId: promotionSession.clientLockId, + promotion: promotionSession, + }, + imageScope, + ), + ).toBe(false); + }); + + it("keeps a regular image paywall in the chat flow when no overlay is open", () => { + expect( + resolveChatUnlockReturnUrl( + { + displayMessageId: "image-1", + messageId: "image-1", + kind: "image", + }, + defaultScope, + ), + ).toBe("/chat"); + }); +}); + +function createPendingUnlock( + input: Pick< + PendingChatUnlock, + "displayMessageId" | "messageId" | "kind" | "returnUrl" + >, +): PendingChatUnlock { + return { + reason: "single_message_unlock", + ...input, + stage: "auth", + createdAt: 1, + }; +} diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 2787f948..01831dfd 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -33,7 +33,7 @@ import { shouldStartExternalBrowserPrompt, } from "./chat-screen.helpers"; import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner"; -import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-flow"; +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"; @@ -64,40 +64,13 @@ export function ChatScreen() { const imageReturnUrl = imageMessageId ? buildChatImageOverlayUrl(imageMessageId) : ROUTES.chat; - const { - unlockPaywallRequest, - requestMessageUnlock, - closeInsufficientCreditsDialog, - confirmInsufficientCreditsDialog, - } = useChatUnlockNavigationFlow({ - returnUrl: ROUTES.chat, - ignoredKind: "image", - promotionScope: "exclude", + const unlockCoordinator = useChatUnlockCoordinator({ + defaultReturnUrl: ROUTES.chat, + imageMessageId, + imageReturnUrl, + promotion: state.promotion, enabled: isPromotionBootstrapReady, }); - const { - unlockPaywallRequest: imageUnlockPaywallRequest, - requestMessageUnlock: requestImageUnlock, - closeInsufficientCreditsDialog: closeImageInsufficientCreditsDialog, - confirmInsufficientCreditsDialog: confirmImageInsufficientCreditsDialog, - } = useChatUnlockNavigationFlow({ - returnUrl: imageReturnUrl, - expectedKind: "image", - expectedMessageId: imageMessageId ?? undefined, - promotionScope: "exclude", - enabled: isPromotionBootstrapReady && imageMessageId !== null, - }); - const { - unlockPaywallRequest: promotionUnlockPaywallRequest, - requestMessageUnlock: requestPromotionUnlock, - closeInsufficientCreditsDialog: closePromotionInsufficientCreditsDialog, - confirmInsufficientCreditsDialog: - confirmPromotionInsufficientCreditsDialog, - } = useChatUnlockNavigationFlow({ - returnUrl: ROUTES.chat, - promotionScope: "only", - enabled: isPromotionBootstrapReady && state.promotion !== null, - }); const selectedImageMessage = useMemo( () => imageMessageId @@ -152,36 +125,15 @@ export function ChatScreen() { ]); function handleUnlockPrivateMessage(messageId: string): void { - if (requestPromotionMessageUnlock(messageId, "private")) return; - requestMessageUnlock(messageId, "private"); + unlockCoordinator.requestMessageUnlock(messageId, "private"); } function handleUnlockVoiceMessage(messageId: string): void { - if (requestPromotionMessageUnlock(messageId, "voice")) return; - requestMessageUnlock(messageId, "voice"); + unlockCoordinator.requestMessageUnlock(messageId, "voice"); } function handleUnlockImageMessage(messageId: string): void { - if (requestPromotionMessageUnlock(messageId, "image")) return; - requestMessageUnlock(messageId, "image"); - } - - function requestPromotionMessageUnlock( - messageId: string, - kind: "private" | "voice" | "image", - ): boolean { - const promotion = state.promotion; - if (!promotion || promotion.message.id !== messageId) return false; - - const temporaryMessageId = `promotion:${promotion.session.clientLockId}`; - requestPromotionUnlock(messageId, kind, { - remoteMessageId: - messageId === temporaryMessageId ? undefined : messageId, - lockType: promotion.session.lockType, - clientLockId: promotion.session.clientLockId, - promotion: promotion.session, - }); - return true; + unlockCoordinator.requestMessageUnlock(messageId, "image"); } function handleOpenImage(messageId: string): void { @@ -196,7 +148,7 @@ export function ChatScreen() { function handleUnlockImagePaywall(): void { if (!imageMessageId) return; - requestImageUnlock(imageMessageId, "image"); + unlockCoordinator.requestMessageUnlock(imageMessageId, "image"); } return ( @@ -264,29 +216,7 @@ export function ChatScreen() { {/* PWA 安装提示触发器(聊天消息数量达到阈值后才允许弹出) */} - - - + {selectedImageMessage?.imageUrl ? ( void; - onConfirmInsufficientCreditsDialog: () => void; - includeHistoryUnlock?: boolean; + model: ChatUnlockDialogModel; } export function ChatUnlockDialogs({ - unlockPaywallRequest, - onCloseInsufficientCreditsDialog, - onConfirmInsufficientCreditsDialog, - includeHistoryUnlock = true, + model, }: ChatUnlockDialogsProps) { - const chatState = useChatSelector( - (state) => ({ - isUnlockingHistory: state.context.isUnlockingHistory, - lockedHistoryCount: state.context.lockedHistoryCount, - unlockHistoryError: state.context.unlockHistoryError, - unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible, - }), - shallowEqual, - ); - const chatDispatch = useChatDispatch(); - return ( <> - {includeHistoryUnlock ? ( - chatDispatch({ type: "ChatUnlockHistoryDismissed" })} - onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })} - /> - ) : null} + ); diff --git a/src/app/chat/hooks/use-chat-unlock-coordinator.ts b/src/app/chat/hooks/use-chat-unlock-coordinator.ts new file mode 100644 index 00000000..756d3b59 --- /dev/null +++ b/src/app/chat/hooks/use-chat-unlock-coordinator.ts @@ -0,0 +1,330 @@ +"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 { 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; + }; + closeHistoryUnlock: () => void; + confirmHistoryUnlock: () => void; + closePaywall: () => void; + confirmPaywall: () => void; +} + +export interface UseChatUnlockCoordinatorInput + extends ChatUnlockCoordinatorScope { + enabled?: boolean; +} + +export interface UseChatUnlockCoordinatorOutput { + requestMessageUnlock: ( + messageId: 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) => ({ + historyLoaded: state.context.historyLoaded, + isUnlockingHistory: state.context.isUnlockingHistory, + lockedHistoryCount: state.context.lockedHistoryCount, + unlockHistoryError: state.context.unlockHistoryError, + unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible, + unlockPaywallRequest: state.context.unlockPaywallRequest, + }), + shallowEqual, + ); + const chatDispatch = useChatDispatch(); + const unlockPaywallRequest = enabled + ? chatState.unlockPaywallRequest + : null; + + 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 }, + ); + }, [isVip, unlockPaywallRequest]); + + useEffect(() => { + if (!enabled) return; + if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return; + + let cancelled = false; + + const resumePendingUnlock = async () => { + const pending = await peekPendingChatUnlock(); + if ( + cancelled || + !pending || + !shouldResumePendingChatUnlock(pending, { + defaultReturnUrl, + imageMessageId, + imageReturnUrl, + }) + ) { + return; + } + + const consumed = await consumePendingChatUnlock(); + if (cancelled || !consumed) return; + + chatDispatch({ + type: "ChatUnlockMessageRequested", + messageId: consumed.displayMessageId, + remoteMessageId: consumed.messageId, + kind: consumed.kind, + lockType: consumed.lockType, + clientLockId: consumed.clientLockId, + }); + }; + + void resumePendingUnlock(); + return () => { + cancelled = true; + }; + }, [ + chatDispatch, + chatState.historyLoaded, + defaultReturnUrl, + enabled, + imageMessageId, + imageReturnUrl, + navigator.isAuthenticatedUser, + ]); + + function requestMessageUnlock( + messageId: string, + kind: PendingChatUnlockKind, + ): void { + const request = resolveMessageUnlockRequest( + { messageId, kind }, + { + defaultReturnUrl, + imageMessageId, + imageReturnUrl, + promotion, + }, + ); + + navigator.startMessageUnlock({ + ...request, + onAuthenticated: () => { + chatDispatch({ + type: "ChatUnlockMessageRequested", + messageId: request.displayMessageId, + remoteMessageId: request.messageId, + kind: request.kind, + lockType: request.lockType, + clientLockId: request.clientLockId, + }); + }, + }); + } + + function closeHistoryUnlock(): void { + chatDispatch({ type: "ChatUnlockHistoryDismissed" }); + } + + function confirmHistoryUnlock(): void { + chatDispatch({ type: "ChatUnlockHistoryConfirmed" }); + } + + function closePaywall(): void { + chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" }); + } + + function confirmPaywall(): void { + if (!unlockPaywallRequest) return; + + const returnUrl = resolveChatUnlockReturnUrl( + unlockPaywallRequest, + { + defaultReturnUrl, + imageMessageId, + imageReturnUrl, + }, + ); + chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" }); + navigator.openSubscriptionForPendingUnlock({ + displayMessageId: unlockPaywallRequest.displayMessageId, + messageId: unlockPaywallRequest.messageId, + kind: unlockPaywallRequest.kind, + lockType: unlockPaywallRequest.lockType, + clientLockId: unlockPaywallRequest.clientLockId, + promotion: unlockPaywallRequest.promotion, + returnUrl, + type: getInsufficientCreditsSubscriptionType(isVip), + analytics: { + entryPoint: "chat_unlock", + triggerReason: unlockPaywallRequest.promotion + ? "ad_landing" + : "insufficient_credits", + }, + }); + } + + return { + requestMessageUnlock, + dialogs: { + unlockPaywallRequest, + historyUnlock: { + open: chatState.unlockHistoryPromptVisible, + lockedCount: chatState.lockedHistoryCount, + isLoading: chatState.isUnlockingHistory, + errorMessage: chatState.unlockHistoryError, + }, + closeHistoryUnlock, + confirmHistoryUnlock, + closePaywall, + confirmPaywall, + }, + }; +} + +export function resolveMessageUnlockRequest( + input: { + messageId: string; + kind: PendingChatUnlockKind; + }, + scope: ChatUnlockCoordinatorScope, +): CoordinatedMessageUnlockRequest { + const matchedPromotion = + scope.promotion?.message.id === input.messageId + ? scope.promotion + : null; + const temporaryPromotionMessageId = matchedPromotion + ? `promotion:${matchedPromotion.session.clientLockId}` + : null; + const target = { + displayMessageId: input.messageId, + ...(input.messageId !== temporaryPromotionMessageId + ? { messageId: input.messageId } + : {}), + 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 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 + ); +} diff --git a/src/app/chat/hooks/use-chat-unlock-navigation-flow.ts b/src/app/chat/hooks/use-chat-unlock-navigation-flow.ts deleted file mode 100644 index 7f96a4f3..00000000 --- a/src/app/chat/hooks/use-chat-unlock-navigation-flow.ts +++ /dev/null @@ -1,264 +0,0 @@ -"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 PendingChatUnlock, - type PendingChatUnlockKind, - type PendingChatPromotion, -} from "@/lib/navigation/chat_unlock_session"; -import { useAppNavigator } from "@/router/use-app-navigator"; -import { - useChatDispatch, - useChatSelector, -} from "@/stores/chat/chat-context"; -import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state"; -import { useUserSelector } from "@/stores/user/user-context"; - -import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers"; - -type PromotionScope = "any" | "only" | "exclude"; - -export interface MessageUnlockOptions { - remoteMessageId?: string; - lockType?: ChatLockType; - clientLockId?: string; - promotion?: PendingChatPromotion; -} - -export interface UseChatUnlockNavigationFlowInput { - returnUrl: string; - expectedKind?: PendingChatUnlockKind; - expectedMessageId?: string; - ignoredKind?: PendingChatUnlockKind; - promotionScope?: PromotionScope; - enabled?: boolean; -} - -export interface UseChatUnlockNavigationFlowOutput { - unlockPaywallRequest: ChatUnlockPaywallRequest | null; - requestMessageUnlock: ( - messageId: string, - kind: PendingChatUnlockKind, - options?: MessageUnlockOptions, - ) => void; - closeInsufficientCreditsDialog: () => void; - confirmInsufficientCreditsDialog: () => void; -} - -export function useChatUnlockNavigationFlow({ - returnUrl, - expectedKind, - expectedMessageId, - ignoredKind, - promotionScope = "any", - enabled = true, -}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput { - const navigator = useAppNavigator(); - const isVip = useUserSelector((state) => state.context.isVip); - const trackedPaywallRef = useRef(null); - const chatState = useChatSelector( - (state) => ({ - historyLoaded: state.context.historyLoaded, - unlockPaywallRequest: state.context.unlockPaywallRequest, - }), - shallowEqual, - ); - const chatDispatch = useChatDispatch(); - const unlockPaywallRequest = getScopedUnlockPaywallRequest({ - request: chatState.unlockPaywallRequest, - expectedKind, - expectedMessageId, - ignoredKind, - promotionScope, - enabled, - }); - - useEffect(() => { - if (!unlockPaywallRequest) return; - const requestKey = `${unlockPaywallRequest.displayMessageId}:${unlockPaywallRequest.clientLockId ?? ""}`; - if (trackedPaywallRef.current === requestKey) return; - trackedPaywallRef.current = requestKey; - behaviorAnalytics.paywallShown( - { - entryPoint: "chat_unlock", - triggerReason: unlockPaywallRequest.promotion - ? "ad_landing" - : "insufficient_credits", - }, - { isVip }, - ); - }, [isVip, unlockPaywallRequest]); - - useEffect(() => { - if (!enabled) return; - if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return; - - let cancelled = false; - - const resumePendingUnlock = async () => { - const pending = await peekPendingChatUnlock(); - if ( - cancelled || - !pending || - pending.returnUrl !== returnUrl || - !matchesPendingUnlockScope({ - pending, - expectedKind, - expectedMessageId, - promotionScope, - }) - ) { - return; - } - - const consumed = await consumePendingChatUnlock(); - if (cancelled || !consumed) return; - - chatDispatch({ - type: "ChatUnlockMessageRequested", - messageId: consumed.displayMessageId, - remoteMessageId: consumed.messageId, - kind: consumed.kind, - lockType: consumed.lockType, - clientLockId: consumed.clientLockId, - }); - }; - - void resumePendingUnlock(); - return () => { - cancelled = true; - }; - }, [ - chatDispatch, - chatState.historyLoaded, - enabled, - expectedKind, - expectedMessageId, - navigator.isAuthenticatedUser, - promotionScope, - returnUrl, - ]); - - function requestMessageUnlock( - messageId: string, - kind: PendingChatUnlockKind, - options: MessageUnlockOptions = {}, - ): void { - const remoteMessageId = - options.remoteMessageId ?? (options.lockType ? undefined : messageId); - navigator.startMessageUnlock({ - displayMessageId: messageId, - messageId: remoteMessageId, - kind, - lockType: options.lockType, - clientLockId: options.clientLockId, - promotion: options.promotion, - returnUrl, - onAuthenticated: () => { - chatDispatch({ - type: "ChatUnlockMessageRequested", - messageId, - remoteMessageId, - kind, - lockType: options.lockType, - clientLockId: options.clientLockId, - }); - }, - }); - } - - function closeInsufficientCreditsDialog(): void { - chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" }); - } - - function confirmInsufficientCreditsDialog(): void { - if (!unlockPaywallRequest) return; - - chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" }); - navigator.openSubscriptionForPendingUnlock({ - displayMessageId: unlockPaywallRequest.displayMessageId, - messageId: unlockPaywallRequest.messageId, - kind: unlockPaywallRequest.kind, - lockType: unlockPaywallRequest.lockType, - clientLockId: unlockPaywallRequest.clientLockId, - promotion: unlockPaywallRequest.promotion, - returnUrl, - type: getInsufficientCreditsSubscriptionType(isVip), - analytics: { - entryPoint: "chat_unlock", - triggerReason: unlockPaywallRequest.promotion - ? "ad_landing" - : "insufficient_credits", - }, - }); - } - - return { - unlockPaywallRequest, - requestMessageUnlock, - closeInsufficientCreditsDialog, - confirmInsufficientCreditsDialog, - }; -} - -function getScopedUnlockPaywallRequest(input: { - request: ChatUnlockPaywallRequest | null; - expectedKind?: PendingChatUnlockKind; - expectedMessageId?: string; - ignoredKind?: PendingChatUnlockKind; - promotionScope?: PromotionScope; - enabled?: boolean; -}): ChatUnlockPaywallRequest | null { - const { - request, - expectedKind, - expectedMessageId, - ignoredKind, - promotionScope = "any", - enabled = true, - } = input; - if (!enabled || !request) return null; - if (promotionScope === "only" && !request.promotion) return null; - if (promotionScope === "exclude" && request.promotion) return null; - if (ignoredKind && request.kind === ignoredKind) return null; - if (expectedKind && request.kind !== expectedKind) return null; - if ( - expectedMessageId && - request.displayMessageId !== expectedMessageId && - request.messageId !== expectedMessageId - ) { - return null; - } - return request; -} - -function matchesPendingUnlockScope(input: { - pending: PendingChatUnlock; - expectedKind?: PendingChatUnlockKind; - expectedMessageId?: string; - promotionScope?: PromotionScope; -}): boolean { - const { - pending, - expectedKind, - expectedMessageId, - promotionScope = "any", - } = input; - if (promotionScope === "only" && !pending.promotion) return false; - if (promotionScope === "exclude" && pending.promotion) return false; - if (expectedKind && pending.kind !== expectedKind) return false; - if ( - expectedMessageId && - pending.displayMessageId !== expectedMessageId && - pending.messageId !== expectedMessageId - ) { - return false; - } - return true; -}