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;
-}