refactor(chat): centralize unlock coordination
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user