refactor(chat): centralize unlock coordination

This commit is contained in:
2026-07-15 19:11:59 +08:00
parent 590dee417b
commit 47876484fc
5 changed files with 546 additions and 387 deletions
@@ -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,
};
}
+11 -81
View File
@@ -33,7 +33,7 @@ import {
shouldStartExternalBrowserPrompt, shouldStartExternalBrowserPrompt,
} from "./chat-screen.helpers"; } from "./chat-screen.helpers";
import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-banner"; 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 { useChatGuestLogin } from "./hooks/use-chat-guest-login";
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner"; import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap"; import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
@@ -64,40 +64,13 @@ export function ChatScreen() {
const imageReturnUrl = imageMessageId const imageReturnUrl = imageMessageId
? buildChatImageOverlayUrl(imageMessageId) ? buildChatImageOverlayUrl(imageMessageId)
: ROUTES.chat; : ROUTES.chat;
const { const unlockCoordinator = useChatUnlockCoordinator({
unlockPaywallRequest, defaultReturnUrl: ROUTES.chat,
requestMessageUnlock, imageMessageId,
closeInsufficientCreditsDialog, imageReturnUrl,
confirmInsufficientCreditsDialog, promotion: state.promotion,
} = useChatUnlockNavigationFlow({
returnUrl: ROUTES.chat,
ignoredKind: "image",
promotionScope: "exclude",
enabled: isPromotionBootstrapReady, 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( const selectedImageMessage = useMemo(
() => () =>
imageMessageId imageMessageId
@@ -152,36 +125,15 @@ export function ChatScreen() {
]); ]);
function handleUnlockPrivateMessage(messageId: string): void { function handleUnlockPrivateMessage(messageId: string): void {
if (requestPromotionMessageUnlock(messageId, "private")) return; unlockCoordinator.requestMessageUnlock(messageId, "private");
requestMessageUnlock(messageId, "private");
} }
function handleUnlockVoiceMessage(messageId: string): void { function handleUnlockVoiceMessage(messageId: string): void {
if (requestPromotionMessageUnlock(messageId, "voice")) return; unlockCoordinator.requestMessageUnlock(messageId, "voice");
requestMessageUnlock(messageId, "voice");
} }
function handleUnlockImageMessage(messageId: string): void { function handleUnlockImageMessage(messageId: string): void {
if (requestPromotionMessageUnlock(messageId, "image")) return; unlockCoordinator.requestMessageUnlock(messageId, "image");
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;
} }
function handleOpenImage(messageId: string): void { function handleOpenImage(messageId: string): void {
@@ -196,7 +148,7 @@ export function ChatScreen() {
function handleUnlockImagePaywall(): void { function handleUnlockImagePaywall(): void {
if (!imageMessageId) return; if (!imageMessageId) return;
requestImageUnlock(imageMessageId, "image"); unlockCoordinator.requestMessageUnlock(imageMessageId, "image");
} }
return ( return (
@@ -264,29 +216,7 @@ export function ChatScreen() {
{/* PWA 安装提示触发器(聊天消息数量达到阈值后才允许弹出) */} {/* PWA 安装提示触发器(聊天消息数量达到阈值后才允许弹出) */}
<PwaInstallOverlay enabled={shouldShowPwaInstall} /> <PwaInstallOverlay enabled={shouldShowPwaInstall} />
<ChatUnlockDialogs <ChatUnlockDialogs model={unlockCoordinator.dialogs} />
unlockPaywallRequest={unlockPaywallRequest}
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
/>
<ChatUnlockDialogs
unlockPaywallRequest={promotionUnlockPaywallRequest}
includeHistoryUnlock={false}
onCloseInsufficientCreditsDialog={
closePromotionInsufficientCreditsDialog
}
onConfirmInsufficientCreditsDialog={
confirmPromotionInsufficientCreditsDialog
}
/>
<ChatUnlockDialogs
unlockPaywallRequest={imageUnlockPaywallRequest}
includeHistoryUnlock={false}
onCloseInsufficientCreditsDialog={closeImageInsufficientCreditsDialog}
onConfirmInsufficientCreditsDialog={
confirmImageInsufficientCreditsDialog
}
/>
{selectedImageMessage?.imageUrl ? ( {selectedImageMessage?.imageUrl ? (
<FullscreenImageViewer <FullscreenImageViewer
+17 -42
View File
@@ -1,59 +1,34 @@
"use client"; "use client";
import { shallowEqual } from "@xstate/react"; import type { ChatUnlockDialogModel } from "../hooks/use-chat-unlock-coordinator";
import {
useChatDispatch,
useChatSelector,
} from "@/stores/chat/chat-context";
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
import { HistoryUnlockDialog } from "./history-unlock-dialog"; import { HistoryUnlockDialog } from "./history-unlock-dialog";
import { InsufficientCreditsDialog } from "./insufficient-credits-dialog"; import { InsufficientCreditsDialog } from "./insufficient-credits-dialog";
export interface ChatUnlockDialogsProps { export interface ChatUnlockDialogsProps {
unlockPaywallRequest: ChatUnlockPaywallRequest | null; model: ChatUnlockDialogModel;
onCloseInsufficientCreditsDialog: () => void;
onConfirmInsufficientCreditsDialog: () => void;
includeHistoryUnlock?: boolean;
} }
export function ChatUnlockDialogs({ export function ChatUnlockDialogs({
unlockPaywallRequest, model,
onCloseInsufficientCreditsDialog,
onConfirmInsufficientCreditsDialog,
includeHistoryUnlock = true,
}: ChatUnlockDialogsProps) { }: 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 ( return (
<> <>
{includeHistoryUnlock ? ( <HistoryUnlockDialog
<HistoryUnlockDialog open={model.historyUnlock.open}
open={chatState.unlockHistoryPromptVisible} lockedCount={model.historyUnlock.lockedCount}
lockedCount={chatState.lockedHistoryCount} isLoading={model.historyUnlock.isLoading}
isLoading={chatState.isUnlockingHistory} errorMessage={model.historyUnlock.errorMessage}
errorMessage={chatState.unlockHistoryError} onClose={model.closeHistoryUnlock}
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })} onConfirm={model.confirmHistoryUnlock}
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })} />
/>
) : null}
<InsufficientCreditsDialog <InsufficientCreditsDialog
open={unlockPaywallRequest !== null} open={model.unlockPaywallRequest !== null}
creditBalance={unlockPaywallRequest?.creditBalance ?? 0} creditBalance={model.unlockPaywallRequest?.creditBalance ?? 0}
requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0} requiredCredits={model.unlockPaywallRequest?.requiredCredits ?? 0}
shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0} shortfallCredits={model.unlockPaywallRequest?.shortfallCredits ?? 0}
onClose={onCloseInsufficientCreditsDialog} onClose={model.closePaywall}
onConfirm={onConfirmInsufficientCreditsDialog} onConfirm={model.confirmPaywall}
/> />
</> </>
); );
@@ -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<string | null>(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<ChatUnlockCoordinatorScope, "promotion">,
): 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
);
}
@@ -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<string | null>(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;
}