Files
cozsweet-frontend-nextjs/src/app/chat/hooks/use-chat-unlock-coordinator.ts
T

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