feat(chat): add promotional external entry flow
This commit is contained in:
@@ -34,6 +34,7 @@ import { useFirstRechargeOfferBanner } from "./hooks/use-first-recharge-offer-ba
|
||||
import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-flow";
|
||||
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";
|
||||
import styles from "./components/chat-screen.module.css";
|
||||
|
||||
export function ChatScreen() {
|
||||
@@ -42,6 +43,10 @@ export function ChatScreen() {
|
||||
const state = useChatState();
|
||||
const authState = useAuthState();
|
||||
const authDispatch = useAuthDispatch();
|
||||
const isPromotionBootstrapReady = useChatPromotionBootstrap();
|
||||
const visibleMessages = isPromotionBootstrapReady
|
||||
? state.messages
|
||||
: state.historyMessages;
|
||||
const imageMessageId = getChatImageOverlayMessageId(searchParams);
|
||||
const imageReturnUrl = imageMessageId
|
||||
? buildChatImageOverlayUrl(imageMessageId)
|
||||
@@ -54,6 +59,8 @@ export function ChatScreen() {
|
||||
} = useChatUnlockNavigationFlow({
|
||||
returnUrl: ROUTES.chat,
|
||||
ignoredKind: "image",
|
||||
promotionScope: "exclude",
|
||||
enabled: isPromotionBootstrapReady,
|
||||
});
|
||||
const {
|
||||
unlockPaywallRequest: imageUnlockPaywallRequest,
|
||||
@@ -64,16 +71,28 @@ export function ChatScreen() {
|
||||
returnUrl: imageReturnUrl,
|
||||
expectedKind: "image",
|
||||
expectedMessageId: imageMessageId ?? undefined,
|
||||
enabled: imageMessageId !== null,
|
||||
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
|
||||
? state.messages.find(
|
||||
? visibleMessages.find(
|
||||
(item) => item.id === imageMessageId && item.imageUrl,
|
||||
) ?? null
|
||||
: null,
|
||||
[imageMessageId, state.messages],
|
||||
[imageMessageId, visibleMessages],
|
||||
);
|
||||
|
||||
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
|
||||
@@ -89,7 +108,8 @@ export function ChatScreen() {
|
||||
historyLoaded: state.historyLoaded,
|
||||
loginStatus: authState.loginStatus,
|
||||
});
|
||||
const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10;
|
||||
const shouldShowPwaInstall =
|
||||
state.historyLoaded && state.historyMessages.length >= 10;
|
||||
const shouldShowBrowserHint = shouldStartExternalBrowserPrompt({
|
||||
hasInitialized: authState.hasInitialized,
|
||||
isLoading: authState.isLoading,
|
||||
@@ -119,13 +139,38 @@ export function ChatScreen() {
|
||||
]);
|
||||
|
||||
function handleUnlockPrivateMessage(messageId: string): void {
|
||||
if (requestPromotionMessageUnlock(messageId, "private")) return;
|
||||
requestMessageUnlock(messageId, "private");
|
||||
}
|
||||
|
||||
function handleUnlockVoiceMessage(messageId: string): void {
|
||||
if (requestPromotionMessageUnlock(messageId, "voice")) return;
|
||||
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;
|
||||
}
|
||||
|
||||
function handleOpenImage(messageId: string): void {
|
||||
router.push(buildChatImageOverlayUrl(messageId), { scroll: false });
|
||||
}
|
||||
@@ -169,12 +214,13 @@ export function ChatScreen() {
|
||||
/>
|
||||
|
||||
<ChatArea
|
||||
messages={state.messages}
|
||||
messages={visibleMessages}
|
||||
isReplyingAI={state.isReplyingAI}
|
||||
isUnlockingMessage={state.isUnlockingMessage}
|
||||
unlockingMessageId={state.unlockingMessageId}
|
||||
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
||||
onUnlockVoiceMessage={handleUnlockVoiceMessage}
|
||||
onUnlockImageMessage={handleUnlockImageMessage}
|
||||
onOpenImage={handleOpenImage}
|
||||
/>
|
||||
|
||||
@@ -201,8 +247,19 @@ export function ChatScreen() {
|
||||
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
|
||||
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
|
||||
/>
|
||||
<ChatUnlockDialogs
|
||||
unlockPaywallRequest={promotionUnlockPaywallRequest}
|
||||
includeHistoryUnlock={false}
|
||||
onCloseInsufficientCreditsDialog={
|
||||
closePromotionInsufficientCreditsDialog
|
||||
}
|
||||
onConfirmInsufficientCreditsDialog={
|
||||
confirmPromotionInsufficientCreditsDialog
|
||||
}
|
||||
/>
|
||||
<ChatUnlockDialogs
|
||||
unlockPaywallRequest={imageUnlockPaywallRequest}
|
||||
includeHistoryUnlock={false}
|
||||
onCloseInsufficientCreditsDialog={closeImageInsufficientCreditsDialog}
|
||||
onConfirmInsufficientCreditsDialog={
|
||||
confirmImageInsufficientCreditsDialog
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LockedImageMessageCard } from "../locked-image-message-card";
|
||||
|
||||
describe("LockedImageMessageCard", () => {
|
||||
it("renders the promotion hint and an enabled unlock action", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<LockedImageMessageCard
|
||||
hint="Unlock this private photo."
|
||||
onUnlock={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('aria-label="Locked private image"');
|
||||
expect(html).toContain("Unlock this private photo.");
|
||||
expect(html).toContain("Unlock private image");
|
||||
expect(html).not.toContain(' disabled=""');
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ export interface ChatAreaProps {
|
||||
unlockingMessageId?: string | null;
|
||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||
onUnlockImageMessage?: (messageId: string) => void;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
@@ -46,6 +47,7 @@ export function ChatArea({
|
||||
unlockingMessageId,
|
||||
onUnlockPrivateMessage,
|
||||
onUnlockVoiceMessage,
|
||||
onUnlockImageMessage,
|
||||
onOpenImage,
|
||||
}: ChatAreaProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -98,6 +100,7 @@ export function ChatArea({
|
||||
unlockingMessageId,
|
||||
onUnlockPrivateMessage,
|
||||
onUnlockVoiceMessage,
|
||||
onUnlockImageMessage,
|
||||
onOpenImage,
|
||||
)}
|
||||
|
||||
@@ -120,6 +123,7 @@ function renderMessagesWithDateHeaders(
|
||||
unlockingMessageId?: string | null,
|
||||
onUnlockPrivateMessage?: (messageId: string) => void,
|
||||
onUnlockVoiceMessage?: (messageId: string) => void,
|
||||
onUnlockImageMessage?: (messageId: string) => void,
|
||||
onOpenImage?: (messageId: string) => void,
|
||||
) {
|
||||
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
||||
@@ -144,6 +148,7 @@ function renderMessagesWithDateHeaders(
|
||||
}
|
||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||
onUnlockImageMessage={onUnlockImageMessage}
|
||||
onOpenImage={onOpenImage}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -10,26 +10,30 @@ export interface ChatUnlockDialogsProps {
|
||||
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
|
||||
onCloseInsufficientCreditsDialog: () => void;
|
||||
onConfirmInsufficientCreditsDialog: () => void;
|
||||
includeHistoryUnlock?: boolean;
|
||||
}
|
||||
|
||||
export function ChatUnlockDialogs({
|
||||
unlockPaywallRequest,
|
||||
onCloseInsufficientCreditsDialog,
|
||||
onConfirmInsufficientCreditsDialog,
|
||||
includeHistoryUnlock = true,
|
||||
}: ChatUnlockDialogsProps) {
|
||||
const chatState = useChatState();
|
||||
const chatDispatch = useChatDispatch();
|
||||
|
||||
return (
|
||||
<>
|
||||
<HistoryUnlockDialog
|
||||
open={chatState.unlockHistoryPromptVisible}
|
||||
lockedCount={chatState.lockedHistoryCount}
|
||||
isLoading={chatState.isUnlockingHistory}
|
||||
errorMessage={chatState.unlockHistoryError}
|
||||
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
|
||||
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
|
||||
/>
|
||||
{includeHistoryUnlock ? (
|
||||
<HistoryUnlockDialog
|
||||
open={chatState.unlockHistoryPromptVisible}
|
||||
lockedCount={chatState.lockedHistoryCount}
|
||||
isLoading={chatState.isUnlockingHistory}
|
||||
errorMessage={chatState.unlockHistoryError}
|
||||
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
|
||||
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
|
||||
/>
|
||||
) : null}
|
||||
<InsufficientCreditsDialog
|
||||
open={unlockPaywallRequest !== null}
|
||||
creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { ImageIcon, LockKeyhole } from "lucide-react";
|
||||
|
||||
export interface LockedImageMessageCardProps {
|
||||
hint?: string | null;
|
||||
isUnlocking?: boolean;
|
||||
onUnlock?: () => void;
|
||||
}
|
||||
|
||||
export function LockedImageMessageCard({
|
||||
hint,
|
||||
isUnlocking = false,
|
||||
onUnlock,
|
||||
}: LockedImageMessageCardProps) {
|
||||
return (
|
||||
<div
|
||||
className="w-[min(72vw,280px)] overflow-hidden rounded-[var(--responsive-card-radius-sm,18px)] rounded-tl-none border border-[rgba(246,87,160,0.2)] bg-white shadow-[0_4px_14px_rgba(246,87,160,0.14)]"
|
||||
role="group"
|
||||
aria-label="Locked private image"
|
||||
>
|
||||
<div className="relative flex h-32 items-center justify-center bg-[linear-gradient(145deg,#ffeaf3,#fff7fb)] text-[#f657a0]">
|
||||
<ImageIcon size={46} strokeWidth={1.5} aria-hidden="true" />
|
||||
<span className="absolute flex size-10 items-center justify-center rounded-full bg-white/90 shadow-sm">
|
||||
<LockKeyhole size={19} aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-[var(--responsive-card-padding,14px)]">
|
||||
<p className="m-0 text-[var(--responsive-body,14px)] leading-[1.4] text-[#3c3b3b]">
|
||||
{hint && hint.length > 0
|
||||
? hint
|
||||
: "Elio sent you a locked image."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-[var(--spacing-md,12px)] w-full cursor-pointer rounded-full border-0 bg-[linear-gradient(90deg,#ff67e0,#ff52a2)] px-[var(--spacing-md,12px)] py-[clamp(9px,1.852vw,10px)] text-[var(--responsive-body,14px)] font-bold text-white disabled:cursor-not-allowed disabled:opacity-65 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
|
||||
disabled={isUnlocking || !onUnlock}
|
||||
onClick={onUnlock}
|
||||
>
|
||||
{isUnlocking ? "Unlocking..." : "Unlock private image"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export interface MessageBubbleProps {
|
||||
isUnlockingMessage?: boolean;
|
||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||
onUnlockImageMessage?: (messageId: string) => void;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ export function MessageBubble({
|
||||
isUnlockingMessage,
|
||||
onUnlockPrivateMessage,
|
||||
onUnlockVoiceMessage,
|
||||
onUnlockImageMessage,
|
||||
onOpenImage,
|
||||
}: MessageBubbleProps) {
|
||||
const { avatarUrl } = useUserState();
|
||||
@@ -72,6 +74,7 @@ export function MessageBubble({
|
||||
isUnlockingMessage={isUnlockingMessage}
|
||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||
onUnlockImageMessage={onUnlockImageMessage}
|
||||
onOpenImage={onOpenImage}
|
||||
/>
|
||||
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
||||
@@ -100,6 +103,7 @@ export function MessageBubble({
|
||||
isUnlockingMessage={isUnlockingMessage}
|
||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||
onUnlockImageMessage={onUnlockImageMessage}
|
||||
onOpenImage={onOpenImage}
|
||||
/>
|
||||
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { ImageBubble } from "./image-bubble";
|
||||
import { LockedImageMessageCard } from "./locked-image-message-card";
|
||||
import { PrivateMessageCard } from "./private-message-card";
|
||||
import { TextBubble } from "./text-bubble";
|
||||
import { VoiceBubble } from "./voice-bubble";
|
||||
@@ -19,6 +20,7 @@ export interface MessageContentProps {
|
||||
isUnlockingMessage?: boolean;
|
||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||
onUnlockImageMessage?: (messageId: string) => void;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
@@ -38,6 +40,7 @@ export function MessageContent({
|
||||
isUnlockingMessage,
|
||||
onUnlockPrivateMessage,
|
||||
onUnlockVoiceMessage,
|
||||
onUnlockImageMessage,
|
||||
onOpenImage,
|
||||
}: MessageContentProps) {
|
||||
const hasImage = imageUrl != null && imageUrl.length > 0;
|
||||
@@ -47,6 +50,9 @@ export function MessageContent({
|
||||
lockedPrivate === true ||
|
||||
(locked === true && lockReason === "private_message");
|
||||
const isLockedVoiceMessage = locked === true && lockReason === "voice_message";
|
||||
const isLockedImageMessage =
|
||||
locked === true &&
|
||||
(lockReason === "image_paywall" || lockReason === "image");
|
||||
const shouldRenderVoiceMessage = hasAudio || isLockedVoiceMessage;
|
||||
const handleUnlockPrivateMessage =
|
||||
messageId && onUnlockPrivateMessage
|
||||
@@ -56,6 +62,10 @@ export function MessageContent({
|
||||
isLockedVoiceMessage && messageId && onUnlockVoiceMessage
|
||||
? () => onUnlockVoiceMessage(messageId)
|
||||
: undefined;
|
||||
const handleUnlockImageMessage =
|
||||
isLockedImageMessage && messageId && onUnlockImageMessage
|
||||
? () => onUnlockImageMessage(messageId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -70,6 +80,12 @@ export function MessageContent({
|
||||
isUnlocking={isUnlockingMessage}
|
||||
onUnlock={handleUnlockPrivateMessage}
|
||||
/>
|
||||
) : isLockedImageMessage && !hasImage ? (
|
||||
<LockedImageMessageCard
|
||||
hint={privateMessageHint}
|
||||
isUnlocking={isUnlockingMessage}
|
||||
onUnlock={handleUnlockImageMessage}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{hasImage && imageUrl && (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
consumePendingChatPromotion,
|
||||
peekPendingChatUnlock,
|
||||
} from "@/lib/navigation/chat_unlock_session";
|
||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||
import { Logger } from "@/utils";
|
||||
|
||||
const log = new Logger("UseChatPromotionBootstrap");
|
||||
|
||||
export function useChatPromotionBootstrap(): boolean {
|
||||
const chatDispatch = useChatDispatch();
|
||||
const startedRef = useRef(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [entryPromotion, pendingUnlock] = await Promise.all([
|
||||
consumePendingChatPromotion(),
|
||||
peekPendingChatUnlock(),
|
||||
]);
|
||||
const promotion = entryPromotion ?? pendingUnlock?.promotion ?? null;
|
||||
|
||||
if (promotion) {
|
||||
chatDispatch({
|
||||
type: "ChatPromotionInjected",
|
||||
promotion,
|
||||
messageId: pendingUnlock?.promotion
|
||||
? pendingUnlock.messageId
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
chatDispatch({ type: "ChatPromotionCleared" });
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Failed to restore chat promotion", error);
|
||||
chatDispatch({ type: "ChatPromotionCleared" });
|
||||
} finally {
|
||||
setIsReady(true);
|
||||
}
|
||||
})();
|
||||
}, [chatDispatch]);
|
||||
|
||||
return isReady;
|
||||
}
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
import {
|
||||
consumePendingChatUnlock,
|
||||
peekPendingChatUnlock,
|
||||
type PendingChatUnlock,
|
||||
type PendingChatUnlockKind,
|
||||
type PendingChatPromotion,
|
||||
} from "@/lib/navigation/chat_unlock_session";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
||||
@@ -15,11 +17,21 @@ import { useUserState } 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;
|
||||
}
|
||||
|
||||
@@ -28,6 +40,7 @@ export interface UseChatUnlockNavigationFlowOutput {
|
||||
requestMessageUnlock: (
|
||||
messageId: string,
|
||||
kind: PendingChatUnlockKind,
|
||||
options?: MessageUnlockOptions,
|
||||
) => void;
|
||||
closeInsufficientCreditsDialog: () => void;
|
||||
confirmInsufficientCreditsDialog: () => void;
|
||||
@@ -38,6 +51,7 @@ export function useChatUnlockNavigationFlow({
|
||||
expectedKind,
|
||||
expectedMessageId,
|
||||
ignoredKind,
|
||||
promotionScope = "any",
|
||||
enabled = true,
|
||||
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
|
||||
const navigator = useAppNavigator();
|
||||
@@ -49,6 +63,7 @@ export function useChatUnlockNavigationFlow({
|
||||
expectedKind,
|
||||
expectedMessageId,
|
||||
ignoredKind,
|
||||
promotionScope,
|
||||
enabled,
|
||||
});
|
||||
|
||||
@@ -68,6 +83,7 @@ export function useChatUnlockNavigationFlow({
|
||||
pending,
|
||||
expectedKind,
|
||||
expectedMessageId,
|
||||
promotionScope,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
@@ -78,8 +94,11 @@ export function useChatUnlockNavigationFlow({
|
||||
|
||||
chatDispatch({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: consumed.messageId,
|
||||
messageId: consumed.displayMessageId,
|
||||
remoteMessageId: consumed.messageId,
|
||||
kind: consumed.kind,
|
||||
lockType: consumed.lockType,
|
||||
clientLockId: consumed.clientLockId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -93,24 +112,34 @@ export function useChatUnlockNavigationFlow({
|
||||
enabled,
|
||||
expectedKind,
|
||||
expectedMessageId,
|
||||
ignoredKind,
|
||||
navigator.isAuthenticatedUser,
|
||||
promotionScope,
|
||||
returnUrl,
|
||||
]);
|
||||
|
||||
function requestMessageUnlock(
|
||||
messageId: string,
|
||||
kind: PendingChatUnlockKind,
|
||||
options: MessageUnlockOptions = {},
|
||||
): void {
|
||||
const remoteMessageId =
|
||||
options.remoteMessageId ?? (options.lockType ? undefined : messageId);
|
||||
navigator.startMessageUnlock({
|
||||
messageId,
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -125,8 +154,12 @@ export function useChatUnlockNavigationFlow({
|
||||
|
||||
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(userState.isVip),
|
||||
});
|
||||
@@ -145,6 +178,7 @@ function getScopedUnlockPaywallRequest(input: {
|
||||
expectedKind?: PendingChatUnlockKind;
|
||||
expectedMessageId?: string;
|
||||
ignoredKind?: PendingChatUnlockKind;
|
||||
promotionScope?: PromotionScope;
|
||||
enabled?: boolean;
|
||||
}): ChatUnlockPaywallRequest | null {
|
||||
const {
|
||||
@@ -152,13 +186,21 @@ function getScopedUnlockPaywallRequest(input: {
|
||||
expectedKind,
|
||||
expectedMessageId,
|
||||
ignoredKind,
|
||||
promotionScope = "any",
|
||||
enabled = true,
|
||||
} = input;
|
||||
if (!enabled) return null;
|
||||
if (!request) return null;
|
||||
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.messageId !== expectedMessageId) return null;
|
||||
if (
|
||||
expectedMessageId &&
|
||||
request.displayMessageId !== expectedMessageId &&
|
||||
request.messageId !== expectedMessageId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -166,10 +208,22 @@ function matchesPendingUnlockScope(input: {
|
||||
pending: PendingChatUnlock;
|
||||
expectedKind?: PendingChatUnlockKind;
|
||||
expectedMessageId?: string;
|
||||
promotionScope?: PromotionScope;
|
||||
}): boolean {
|
||||
const { pending, expectedKind, expectedMessageId } = input;
|
||||
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.messageId !== expectedMessageId) {
|
||||
if (
|
||||
expectedMessageId &&
|
||||
pending.displayMessageId !== expectedMessageId &&
|
||||
pending.messageId !== expectedMessageId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user