333 lines
9.0 KiB
TypeScript
333 lines
9.0 KiB
TypeScript
"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) => ({
|
|
characterId: state.context.characterId,
|
|
historyLoaded: state.context.historyLoaded,
|
|
isUnlockingHistory: state.matches({ userSession: "unlockingHistory" }),
|
|
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(chatState.characterId);
|
|
if (
|
|
cancelled ||
|
|
!pending ||
|
|
!shouldResumePendingChatUnlock(pending, {
|
|
defaultReturnUrl,
|
|
imageMessageId,
|
|
imageReturnUrl,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const consumed = await consumePendingChatUnlock(chatState.characterId);
|
|
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.characterId,
|
|
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
|
|
);
|
|
}
|