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;
|
||||
|
||||
@@ -5,8 +5,14 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
persistExternalEntryPayload,
|
||||
resolveExternalEntryPromotionType,
|
||||
resolveExternalEntryTarget,
|
||||
} from "@/lib/navigation/external_entry";
|
||||
import {
|
||||
clearPendingChatPromotion,
|
||||
savePendingChatPromotion,
|
||||
} from "@/lib/navigation/chat_unlock_session";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { Logger } from "@/utils";
|
||||
|
||||
const log = new Logger("ExternalEntryPersist");
|
||||
@@ -17,8 +23,8 @@ interface ExternalEntryPersistProps {
|
||||
psid: string | null;
|
||||
avatarUrl: string | null;
|
||||
target: string | null;
|
||||
redirect: string | null;
|
||||
next: string | null;
|
||||
mode: string | null;
|
||||
promotionType: string | null;
|
||||
}
|
||||
|
||||
export default function ExternalEntryPersist({
|
||||
@@ -27,26 +33,31 @@ export default function ExternalEntryPersist({
|
||||
psid,
|
||||
avatarUrl,
|
||||
target,
|
||||
redirect,
|
||||
next,
|
||||
mode,
|
||||
promotionType,
|
||||
}: ExternalEntryPersistProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const destination = resolveExternalEntryTarget({
|
||||
target,
|
||||
redirect,
|
||||
next,
|
||||
const destination = resolveExternalEntryTarget({ target });
|
||||
const resolvedPromotionType = resolveExternalEntryPromotionType({
|
||||
mode,
|
||||
promotionType,
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await persistExternalEntryPayload({
|
||||
deviceId,
|
||||
asid,
|
||||
psid,
|
||||
avatarUrl,
|
||||
});
|
||||
await Promise.all([
|
||||
persistExternalEntryPayload({
|
||||
deviceId,
|
||||
asid,
|
||||
psid,
|
||||
avatarUrl,
|
||||
}),
|
||||
destination === ROUTES.chat && resolvedPromotionType
|
||||
? savePendingChatPromotion(resolvedPromotionType)
|
||||
: clearPendingChatPromotion(),
|
||||
]);
|
||||
} catch (error) {
|
||||
log.warn("[ExternalEntryPersist] failed to persist payload", error);
|
||||
} finally {
|
||||
@@ -57,9 +68,9 @@ export default function ExternalEntryPersist({
|
||||
avatarUrl,
|
||||
asid,
|
||||
deviceId,
|
||||
next,
|
||||
mode,
|
||||
promotionType,
|
||||
psid,
|
||||
redirect,
|
||||
router,
|
||||
target,
|
||||
]);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* 外部应用可以通过 query 传入 Facebook 相关信息和最终目标页,例如:
|
||||
* `/external-entry?target=chat&asid=xxx&psid=yyy`
|
||||
* `/external-entry?target=chat&mode=promotion&promotion_type=voice`
|
||||
*
|
||||
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
|
||||
* 再通过 `router.replace()` 清理 URL 并跳转到最终页面。
|
||||
@@ -23,27 +24,20 @@ export default async function ExternalEntryPage({
|
||||
|
||||
return (
|
||||
<ExternalEntryPersist
|
||||
deviceId={pickFirst(params.deviceId, params.device_id)}
|
||||
asid={pickFirst(params.asid)}
|
||||
psid={pickFirst(params.psid)}
|
||||
avatarUrl={pickFirst(params.avatarUrl, params.avatar_url)}
|
||||
target={pickFirst(params.target)}
|
||||
redirect={pickFirst(params.redirect)}
|
||||
next={pickFirst(params.next)}
|
||||
deviceId={pickParam(params.device_id)}
|
||||
asid={pickParam(params.asid)}
|
||||
psid={pickParam(params.psid)}
|
||||
avatarUrl={pickParam(params.avatar_url)}
|
||||
target={pickParam(params.target)}
|
||||
mode={pickParam(params.mode)}
|
||||
promotionType={pickParam(params.promotion_type)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function pickFirst(
|
||||
...values: Array<string | string[] | undefined>
|
||||
): string | null {
|
||||
for (const value of values) {
|
||||
if (Array.isArray(value)) {
|
||||
const first = value.find((item) => item.trim().length > 0);
|
||||
if (first) return first;
|
||||
continue;
|
||||
}
|
||||
if (value && value.trim().length > 0) return value;
|
||||
function pickParam(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value.find((item) => item.trim().length > 0) ?? null;
|
||||
}
|
||||
return null;
|
||||
return value && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { UnlockPrivateRequest } from "@/data/dto/chat";
|
||||
|
||||
describe("UnlockPrivateRequest", () => {
|
||||
it("serializes an existing backend message unlock", () => {
|
||||
expect(
|
||||
UnlockPrivateRequest.from({ messageId: "message-1" }).toJson(),
|
||||
).toEqual({ messageId: "message-1" });
|
||||
});
|
||||
|
||||
it("serializes a temporary promotion lock without a fake message id", () => {
|
||||
expect(
|
||||
UnlockPrivateRequest.from({
|
||||
lockType: "voice_message",
|
||||
clientLockId: "promotion-1",
|
||||
}).toJson(),
|
||||
).toEqual({
|
||||
lockType: "voice_message",
|
||||
clientLockId: "promotion-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,34 @@ describe("UnlockPrivateResponse", () => {
|
||||
expect(response.lockDetail.locked).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes backend ids, audio aliases, and unlocked images", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
unlocked: true,
|
||||
message_id: "backend-message-1",
|
||||
clientLockId: "promotion-1",
|
||||
lockType: "image_paywall",
|
||||
content: null,
|
||||
reply: "Unlocked reply",
|
||||
audio_url: "https://example.com/audio.mp3",
|
||||
image: {
|
||||
type: "promotion",
|
||||
url: "https://example.com/image.jpg",
|
||||
},
|
||||
lockDetail: {
|
||||
locked: false,
|
||||
showContent: true,
|
||||
showUpgrade: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.messageId).toBe("backend-message-1");
|
||||
expect(response.clientLockId).toBe("promotion-1");
|
||||
expect(response.lockType).toBe("image_paywall");
|
||||
expect(response.content).toBe("Unlocked reply");
|
||||
expect(response.audioUrl).toBe("https://example.com/audio.mp3");
|
||||
expect(response.image.url).toBe("https://example.com/image.jpg");
|
||||
});
|
||||
|
||||
it("parses an insufficient-balance unlock response", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
unlocked: false,
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
} from "@/data/schemas/chat/unlock_private_request";
|
||||
|
||||
export class UnlockPrivateRequest {
|
||||
declare readonly messageId: string;
|
||||
declare readonly messageId?: string;
|
||||
declare readonly lockType?: UnlockPrivateRequestData["lockType"];
|
||||
declare readonly clientLockId?: string;
|
||||
|
||||
private constructor(input: UnlockPrivateRequestInput) {
|
||||
const data = UnlockPrivateRequestSchema.parse(input);
|
||||
|
||||
@@ -5,14 +5,19 @@ import {
|
||||
type UnlockPrivateResponseInput,
|
||||
} from "@/data/schemas/chat/unlock_private_response";
|
||||
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
||||
import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
|
||||
|
||||
/**
|
||||
* 单条历史付费 / 私密消息解锁响应 DTO。
|
||||
*/
|
||||
export class UnlockPrivateResponse {
|
||||
declare readonly unlocked: boolean;
|
||||
declare readonly messageId: string;
|
||||
declare readonly clientLockId: string | null;
|
||||
declare readonly lockType: ChatLockType | null;
|
||||
declare readonly content: string;
|
||||
declare readonly audioUrl: string;
|
||||
declare readonly image: ChatImageData;
|
||||
declare readonly reason: UnlockPrivateReason;
|
||||
declare readonly creditBalance: number;
|
||||
declare readonly creditsCharged: number;
|
||||
|
||||
@@ -26,6 +26,7 @@ export class ChatLocalMessageStore {
|
||||
...message.toJson(),
|
||||
content: normalizeUnlockedContent(patch.content, message.content),
|
||||
audioUrl: normalizeUnlockedAudioUrl(patch.audioUrl, message.audioUrl),
|
||||
image: patch.image ?? message.image,
|
||||
lockDetail: patch.lockDetail ?? {
|
||||
...message.lockDetail,
|
||||
locked: false,
|
||||
@@ -132,6 +133,8 @@ function shouldMarkMessageUnlocked(
|
||||
if (message.lockDetail.locked !== true) return false;
|
||||
return (
|
||||
Boolean(message.image.url) ||
|
||||
message.lockDetail.reason === "image_paywall" ||
|
||||
message.lockDetail.reason === "image" ||
|
||||
message.lockDetail.reason === "private_message" ||
|
||||
message.lockDetail.reason === "voice_message"
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UnlockPrivateResponse,
|
||||
} from "@/data/dto/chat";
|
||||
import type { ChatApi } from "@/data/services/api";
|
||||
import type { UnlockPrivateMessageInput } from "@/data/repositories/interfaces";
|
||||
import { Result } from "@/utils";
|
||||
|
||||
export class ChatRemoteDataSource {
|
||||
@@ -36,10 +37,10 @@ export class ChatRemoteDataSource {
|
||||
}
|
||||
|
||||
async unlockPrivateMessage(
|
||||
messageId: string,
|
||||
input: UnlockPrivateMessageInput,
|
||||
): Promise<Result<UnlockPrivateResponse>> {
|
||||
return Result.wrap(() =>
|
||||
this.api.unlockPrivateMessage(UnlockPrivateRequest.from({ messageId })),
|
||||
this.api.unlockPrivateMessage(UnlockPrivateRequest.from(input)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
CacheRemoteChatMediaInput,
|
||||
ChatMediaLookupInput,
|
||||
IChatRepository,
|
||||
UnlockPrivateMessageInput,
|
||||
UnlockedPrivateMessageLocalPatch,
|
||||
} from "@/data/repositories/interfaces";
|
||||
import type { Result } from "@/utils";
|
||||
@@ -53,9 +54,9 @@ export class ChatRepository implements IChatRepository {
|
||||
|
||||
/** 解锁单条历史付费 / 私密消息。 */
|
||||
async unlockPrivateMessage(
|
||||
messageId: string,
|
||||
input: UnlockPrivateMessageInput,
|
||||
): Promise<Result<UnlockPrivateResponse>> {
|
||||
return this.remote.unlockPrivateMessage(messageId);
|
||||
return this.remote.unlockPrivateMessage(input);
|
||||
}
|
||||
|
||||
/** 一键解锁历史锁定消息。 */
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
UnlockPrivateResponse,
|
||||
} from "@/data/dto/chat";
|
||||
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
||||
import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
|
||||
import type { LocalChatMediaRow } from "@/data/storage/chat";
|
||||
|
||||
export interface ChatMediaLookupInput {
|
||||
@@ -35,9 +36,16 @@ export interface CacheRemoteChatMediaInput extends ChatMediaLookupInput {
|
||||
export interface UnlockedPrivateMessageLocalPatch {
|
||||
lockDetail?: ChatLockDetailData;
|
||||
audioUrl?: string | null;
|
||||
image?: ChatImageData;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface UnlockPrivateMessageInput {
|
||||
messageId?: string;
|
||||
lockType?: ChatLockType;
|
||||
clientLockId?: string;
|
||||
}
|
||||
|
||||
export interface IChatRepository {
|
||||
/** 发送一条消息。 */
|
||||
sendMessage(
|
||||
@@ -49,7 +57,9 @@ export interface IChatRepository {
|
||||
getHistory(limit?: number, offset?: number): Promise<Result<ChatHistoryResponse>>;
|
||||
|
||||
/** 解锁单条历史付费 / 私密消息。 */
|
||||
unlockPrivateMessage(messageId: string): Promise<Result<UnlockPrivateResponse>>;
|
||||
unlockPrivateMessage(
|
||||
input: UnlockPrivateMessageInput,
|
||||
): Promise<Result<UnlockPrivateResponse>>;
|
||||
|
||||
/** 一键解锁历史锁定消息。 */
|
||||
unlockHistory(): Promise<Result<UnlockHistoryResponse>>;
|
||||
|
||||
@@ -3,9 +3,23 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
export const UnlockPrivateRequestSchema = z.object({
|
||||
messageId: z.string(),
|
||||
});
|
||||
export const ChatLockTypeSchema = z.enum([
|
||||
"voice_message",
|
||||
"image_paywall",
|
||||
"private_message",
|
||||
]);
|
||||
|
||||
export const UnlockPrivateRequestSchema = z
|
||||
.object({
|
||||
messageId: z.string().min(1).optional(),
|
||||
lockType: ChatLockTypeSchema.optional(),
|
||||
clientLockId: z.string().min(1).optional(),
|
||||
})
|
||||
.refine((value) => value.messageId || value.lockType, {
|
||||
message: "messageId or lockType is required",
|
||||
});
|
||||
|
||||
export type ChatLockType = z.output<typeof ChatLockTypeSchema>;
|
||||
|
||||
export type UnlockPrivateRequestInput = z.input<
|
||||
typeof UnlockPrivateRequestSchema
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { booleanOrFalse, numberOrZero, stringOrEmpty } from "../nullable-defaults";
|
||||
import { ChatLockDetailSchema } from "./chat_payloads";
|
||||
import {
|
||||
booleanOrFalse,
|
||||
numberOrZero,
|
||||
stringOrEmpty,
|
||||
stringOrNull,
|
||||
} from "../nullable-defaults";
|
||||
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
|
||||
import { ChatLockTypeSchema } from "./unlock_private_request";
|
||||
|
||||
/**
|
||||
* 单条历史付费 / 私密消息解锁响应。
|
||||
*/
|
||||
export const UnlockPrivateReasonSchema = stringOrEmpty;
|
||||
|
||||
export const UnlockPrivateResponseSchema = z.object({
|
||||
unlocked: booleanOrFalse,
|
||||
content: stringOrEmpty,
|
||||
audioUrl: stringOrEmpty,
|
||||
reason: UnlockPrivateReasonSchema,
|
||||
creditBalance: numberOrZero,
|
||||
creditsCharged: numberOrZero,
|
||||
requiredCredits: numberOrZero,
|
||||
shortfallCredits: numberOrZero,
|
||||
lockDetail: ChatLockDetailSchema,
|
||||
});
|
||||
export const UnlockPrivateResponseSchema = z
|
||||
.object({
|
||||
unlocked: booleanOrFalse,
|
||||
content: stringOrEmpty,
|
||||
reply: stringOrEmpty,
|
||||
messageId: stringOrEmpty,
|
||||
message_id: stringOrEmpty,
|
||||
clientLockId: stringOrNull,
|
||||
lockType: ChatLockTypeSchema.nullable().default(null),
|
||||
audioUrl: stringOrEmpty,
|
||||
audio_url: stringOrEmpty,
|
||||
image: ChatImageSchema,
|
||||
reason: UnlockPrivateReasonSchema,
|
||||
creditBalance: numberOrZero,
|
||||
creditsCharged: numberOrZero,
|
||||
requiredCredits: numberOrZero,
|
||||
shortfallCredits: numberOrZero,
|
||||
lockDetail: ChatLockDetailSchema,
|
||||
})
|
||||
.transform(({ reply, message_id, audio_url, ...data }) => ({
|
||||
...data,
|
||||
content: data.content || reply,
|
||||
messageId: data.messageId || message_id,
|
||||
audioUrl: data.audioUrl || audio_url,
|
||||
}));
|
||||
|
||||
export type UnlockPrivateReason = z.output<typeof UnlockPrivateReasonSchema>;
|
||||
export type UnlockPrivateResponseInput = z.input<
|
||||
|
||||
@@ -52,6 +52,37 @@ describe("NavigationStorage", () => {
|
||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("stores a one-time promotion and carries it through unlock navigation", async () => {
|
||||
const promotion = await NavigationStorage.savePendingChatPromotion("image");
|
||||
|
||||
expect(promotion).toMatchObject({
|
||||
promotionType: "image",
|
||||
lockType: "image_paywall",
|
||||
});
|
||||
expect(promotion.clientLockId).toMatch(/^promotion_/);
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatPromotion(),
|
||||
).resolves.toEqual(promotion);
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatPromotion(),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
displayMessageId: `promotion:${promotion.clientLockId}`,
|
||||
kind: "image",
|
||||
lockType: promotion.lockType,
|
||||
clientLockId: promotion.clientLockId,
|
||||
promotion,
|
||||
returnUrl: "/chat",
|
||||
stage: "auth",
|
||||
});
|
||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({
|
||||
lockType: "image_paywall",
|
||||
clientLockId: promotion.clientLockId,
|
||||
promotion,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves and consumes pending chat image return sessions", async () => {
|
||||
await NavigationStorage.savePendingChatImageReturn({
|
||||
messageId: "msg_1",
|
||||
|
||||
@@ -3,28 +3,45 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||
import { ChatLockTypeSchema } from "@/data/schemas/chat";
|
||||
import { Result, SessionAsyncUtil } from "@/utils";
|
||||
|
||||
const MAX_AGE_MS = 30 * 60 * 1000;
|
||||
|
||||
export type PendingChatUnlockKind = "private" | "voice" | "image";
|
||||
export type PendingChatUnlockStage = "auth" | "payment";
|
||||
export type PendingChatPromotionType = "voice" | "image" | "private";
|
||||
|
||||
const PendingChatUnlockSchema = z.object({
|
||||
reason: z.literal("single_message_unlock"),
|
||||
messageId: z.string().min(1),
|
||||
kind: z.enum(["private", "voice", "image"]),
|
||||
returnUrl: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => value.startsWith("/") && !value.startsWith("//"),
|
||||
"returnUrl must be an internal route",
|
||||
),
|
||||
stage: z.enum(["auth", "payment"]),
|
||||
export const PendingChatPromotionSchema = z.object({
|
||||
promotionType: z.enum(["voice", "image", "private"]),
|
||||
lockType: ChatLockTypeSchema,
|
||||
clientLockId: z.string().min(1),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
const PendingChatUnlockSchema = z
|
||||
.object({
|
||||
reason: z.literal("single_message_unlock"),
|
||||
displayMessageId: z.string().min(1),
|
||||
messageId: z.string().min(1).optional(),
|
||||
kind: z.enum(["private", "voice", "image"]),
|
||||
lockType: ChatLockTypeSchema.optional(),
|
||||
clientLockId: z.string().min(1).optional(),
|
||||
promotion: PendingChatPromotionSchema.optional(),
|
||||
returnUrl: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => value.startsWith("/") && !value.startsWith("//"),
|
||||
"returnUrl must be an internal route",
|
||||
),
|
||||
stage: z.enum(["auth", "payment"]),
|
||||
createdAt: z.number(),
|
||||
})
|
||||
.refine((value) => value.messageId || value.lockType, {
|
||||
message: "messageId or lockType is required",
|
||||
});
|
||||
|
||||
const PendingChatImageReturnSchema = z.object({
|
||||
reason: z.literal("image_paywall"),
|
||||
messageId: z.string().min(1),
|
||||
@@ -39,6 +56,9 @@ const PendingChatImageReturnSchema = z.object({
|
||||
});
|
||||
|
||||
export type PendingChatUnlock = z.output<typeof PendingChatUnlockSchema>;
|
||||
export type PendingChatPromotion = z.output<
|
||||
typeof PendingChatPromotionSchema
|
||||
>;
|
||||
export type PendingChatImageReturn = z.output<
|
||||
typeof PendingChatImageReturnSchema
|
||||
>;
|
||||
@@ -54,15 +74,30 @@ export class NavigationStorage {
|
||||
private constructor() {}
|
||||
|
||||
static async savePendingChatUnlock(input: {
|
||||
messageId: string;
|
||||
displayMessageId?: string;
|
||||
messageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: PendingChatUnlock["lockType"];
|
||||
clientLockId?: string;
|
||||
promotion?: PendingChatPromotion;
|
||||
returnUrl: string;
|
||||
stage: PendingChatUnlockStage;
|
||||
}): Promise<void> {
|
||||
const displayMessageId =
|
||||
input.displayMessageId ??
|
||||
input.messageId ??
|
||||
(input.clientLockId ? `promotion:${input.clientLockId}` : null);
|
||||
if (!displayMessageId) {
|
||||
throw new Error("displayMessageId is required");
|
||||
}
|
||||
const payload: PendingChatUnlock = {
|
||||
reason: "single_message_unlock",
|
||||
messageId: input.messageId,
|
||||
displayMessageId,
|
||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||
kind: input.kind,
|
||||
...(input.lockType ? { lockType: input.lockType } : {}),
|
||||
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
||||
...(input.promotion ? { promotion: input.promotion } : {}),
|
||||
returnUrl: input.returnUrl,
|
||||
stage: input.stage,
|
||||
createdAt: Date.now(),
|
||||
@@ -105,6 +140,37 @@ export class NavigationStorage {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
}
|
||||
|
||||
static async savePendingChatPromotion(
|
||||
promotionType: PendingChatPromotionType,
|
||||
): Promise<PendingChatPromotion> {
|
||||
const promotion: PendingChatPromotion = {
|
||||
promotionType,
|
||||
lockType: toPromotionLockType(promotionType),
|
||||
clientLockId: `promotion_${createUuidV4()}`,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await SessionAsyncUtil.setJson(
|
||||
StorageKeys.pendingChatPromotion,
|
||||
promotion,
|
||||
PendingChatPromotionSchema,
|
||||
);
|
||||
return promotion;
|
||||
}
|
||||
|
||||
static async consumePendingChatPromotion(): Promise<PendingChatPromotion | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatPromotion,
|
||||
PendingChatPromotionSchema,
|
||||
);
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||
if (Result.isErr(result)) return null;
|
||||
return NavigationStorage.parsePendingChatPromotion(result.data);
|
||||
}
|
||||
|
||||
static async clearPendingChatPromotion(): Promise<void> {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||
}
|
||||
|
||||
static async savePendingChatImageReturn(input: {
|
||||
messageId: string;
|
||||
returnUrl: string;
|
||||
@@ -147,4 +213,46 @@ export class NavigationStorage {
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static parsePendingChatPromotion(
|
||||
value: PendingChatPromotion | null,
|
||||
): PendingChatPromotion | null {
|
||||
if (!value) return null;
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function toPromotionLockType(
|
||||
promotionType: PendingChatPromotionType,
|
||||
): PendingChatPromotion["lockType"] {
|
||||
if (promotionType === "voice") return "voice_message";
|
||||
if (promotionType === "image") return "image_paywall";
|
||||
return "private_message";
|
||||
}
|
||||
|
||||
function createUuidV4(): string {
|
||||
const cryptoApi = globalThis.crypto;
|
||||
if (typeof cryptoApi?.randomUUID === "function") {
|
||||
return cryptoApi.randomUUID();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
if (typeof cryptoApi?.getRandomValues === "function") {
|
||||
cryptoApi.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
||||
return [
|
||||
hex.slice(0, 4).join(""),
|
||||
hex.slice(4, 6).join(""),
|
||||
hex.slice(6, 8).join(""),
|
||||
hex.slice(8, 10).join(""),
|
||||
hex.slice(10, 16).join(""),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export const StorageKeys = {
|
||||
chatHistory: "chat_history",
|
||||
pendingChatImageReturn: "pending_chat_image_return",
|
||||
pendingChatUnlock: "pending_chat_unlock",
|
||||
pendingChatPromotion: "pending_chat_promotion",
|
||||
|
||||
// pwa / app info
|
||||
pwaDialogShown: "pwa_dialog_shown",
|
||||
|
||||
@@ -25,10 +25,10 @@ export async function openChatInExternalBrowser(): Promise<void> {
|
||||
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.externalEntry, {
|
||||
queryParams: {
|
||||
target: "chat",
|
||||
deviceId,
|
||||
device_id: deviceId,
|
||||
asid,
|
||||
...(psid ? { psid } : {}),
|
||||
...(avatarUrl ? { avatarUrl } : {}),
|
||||
...(avatarUrl ? { avatar_url: avatarUrl } : {}),
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -2,46 +2,60 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
import { resolveExternalEntryTarget } from "../external_entry";
|
||||
import {
|
||||
resolveExternalEntryPromotionType,
|
||||
resolveExternalEntryTarget,
|
||||
} from "../external_entry";
|
||||
|
||||
describe("external entry navigation", () => {
|
||||
it("defaults to chat", () => {
|
||||
expect(resolveExternalEntryTarget({})).toBe(ROUTES.chat);
|
||||
});
|
||||
|
||||
it("resolves supported target aliases", () => {
|
||||
it("resolves only canonical targets", () => {
|
||||
expect(resolveExternalEntryTarget({ target: "chat" })).toBe(ROUTES.chat);
|
||||
expect(resolveExternalEntryTarget({ target: "tip" })).toBe(ROUTES.tip);
|
||||
expect(resolveExternalEntryTarget({ target: "coffee" })).toBe(ROUTES.tip);
|
||||
expect(resolveExternalEntryTarget({ target: "private-room" })).toBe(
|
||||
ROUTES.privateRoom,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects removed aliases and unsupported targets", () => {
|
||||
expect(resolveExternalEntryTarget({ target: "tips" })).toBe(ROUTES.chat);
|
||||
expect(resolveExternalEntryTarget({ target: "coffee" })).toBe(ROUTES.chat);
|
||||
expect(resolveExternalEntryTarget({ target: "private_room" })).toBe(
|
||||
ROUTES.privateRoom,
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers safe explicit redirect routes over target aliases", () => {
|
||||
expect(
|
||||
resolveExternalEntryTarget({
|
||||
target: "chat",
|
||||
redirect: ROUTES.tip,
|
||||
}),
|
||||
).toBe(ROUTES.tip);
|
||||
});
|
||||
|
||||
it("rejects unsupported and external redirect routes", () => {
|
||||
expect(
|
||||
resolveExternalEntryTarget({
|
||||
redirect: "https://example.com/chat",
|
||||
target: "unknown",
|
||||
}),
|
||||
).toBe(ROUTES.chat);
|
||||
expect(resolveExternalEntryTarget({ redirect: "//example.com" })).toBe(
|
||||
ROUTES.chat,
|
||||
);
|
||||
expect(resolveExternalEntryTarget({ redirect: "/sidebar" })).toBe(
|
||||
ROUTES.chat,
|
||||
);
|
||||
expect(resolveExternalEntryTarget({ target: "/tip" })).toBe(ROUTES.chat);
|
||||
expect(resolveExternalEntryTarget({ target: "sidebar" })).toBe(ROUTES.chat);
|
||||
});
|
||||
});
|
||||
|
||||
describe("external entry promotion", () => {
|
||||
it("accepts only explicit promotion mode and supported message types", () => {
|
||||
expect(
|
||||
resolveExternalEntryPromotionType({
|
||||
mode: "promotion",
|
||||
promotionType: "voice",
|
||||
}),
|
||||
).toBe("voice");
|
||||
expect(
|
||||
resolveExternalEntryPromotionType({
|
||||
mode: "promotion",
|
||||
promotionType: "image",
|
||||
}),
|
||||
).toBe("image");
|
||||
expect(
|
||||
resolveExternalEntryPromotionType({
|
||||
mode: "normal",
|
||||
promotionType: "private",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveExternalEntryPromotionType({
|
||||
mode: "promotion",
|
||||
promotionType: "video",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ describe("subscription exit helpers", () => {
|
||||
it("peeks chat unlock return urls without clearing them", async () => {
|
||||
peekPendingChatUnlockMock.mockResolvedValue({
|
||||
reason: "single_message_unlock",
|
||||
displayMessageId: "msg_1",
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
@@ -85,6 +86,7 @@ describe("subscription exit helpers", () => {
|
||||
consumePendingChatImageReturnMock.mockResolvedValue(null);
|
||||
peekPendingChatUnlockMock.mockResolvedValue({
|
||||
reason: "single_message_unlock",
|
||||
displayMessageId: "msg_1",
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
|
||||
@@ -4,18 +4,26 @@ import {
|
||||
NavigationStorage,
|
||||
type PendingChatUnlock,
|
||||
type PendingChatUnlockKind,
|
||||
type PendingChatPromotion,
|
||||
type PendingChatPromotionType,
|
||||
type PendingChatUnlockStage,
|
||||
} from "@/data/storage/navigation";
|
||||
|
||||
export type {
|
||||
PendingChatUnlock,
|
||||
PendingChatUnlockKind,
|
||||
PendingChatPromotion,
|
||||
PendingChatPromotionType,
|
||||
PendingChatUnlockStage,
|
||||
};
|
||||
|
||||
export async function savePendingChatUnlock(input: {
|
||||
messageId: string;
|
||||
displayMessageId?: string;
|
||||
messageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: PendingChatUnlock["lockType"];
|
||||
clientLockId?: string;
|
||||
promotion?: PendingChatPromotion;
|
||||
returnUrl: string;
|
||||
stage: PendingChatUnlockStage;
|
||||
}): Promise<void> {
|
||||
@@ -37,3 +45,17 @@ export async function hasPendingChatUnlock(): Promise<boolean> {
|
||||
export async function clearPendingChatUnlock(): Promise<void> {
|
||||
await NavigationStorage.clearPendingChatUnlock();
|
||||
}
|
||||
|
||||
export async function savePendingChatPromotion(
|
||||
promotionType: PendingChatPromotionType,
|
||||
): Promise<PendingChatPromotion> {
|
||||
return NavigationStorage.savePendingChatPromotion(promotionType);
|
||||
}
|
||||
|
||||
export async function consumePendingChatPromotion(): Promise<PendingChatPromotion | null> {
|
||||
return NavigationStorage.consumePendingChatPromotion();
|
||||
}
|
||||
|
||||
export async function clearPendingChatPromotion(): Promise<void> {
|
||||
await NavigationStorage.clearPendingChatPromotion();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||
import type { PendingChatPromotionType } from "@/data/storage/navigation";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
export type ExternalEntryTarget =
|
||||
@@ -18,21 +19,34 @@ export interface ExternalEntryPayload {
|
||||
|
||||
export interface ExternalEntryTargetInput {
|
||||
target?: string | null;
|
||||
redirect?: string | null;
|
||||
next?: string | null;
|
||||
}
|
||||
|
||||
export interface ExternalEntryPromotionInput {
|
||||
mode?: string | null;
|
||||
promotionType?: string | null;
|
||||
}
|
||||
|
||||
export function resolveExternalEntryPromotionType({
|
||||
mode,
|
||||
promotionType,
|
||||
}: ExternalEntryPromotionInput): PendingChatPromotionType | null {
|
||||
if (mode?.trim().toLowerCase() !== "promotion") return null;
|
||||
|
||||
const normalizedType = promotionType?.trim().toLowerCase();
|
||||
if (
|
||||
normalizedType === "voice" ||
|
||||
normalizedType === "image" ||
|
||||
normalizedType === "private"
|
||||
) {
|
||||
return normalizedType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveExternalEntryTarget({
|
||||
target,
|
||||
redirect,
|
||||
next,
|
||||
}: ExternalEntryTargetInput): ExternalEntryTarget {
|
||||
return (
|
||||
resolveExplicitRoute(redirect) ??
|
||||
resolveExplicitRoute(next) ??
|
||||
resolveTargetAlias(target) ??
|
||||
ROUTES.chat
|
||||
);
|
||||
return resolveTarget(target) ?? ROUTES.chat;
|
||||
}
|
||||
|
||||
export async function persistExternalEntryPayload({
|
||||
@@ -61,44 +75,19 @@ export async function persistExternalEntryPayload({
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
function resolveExplicitRoute(
|
||||
value: string | null | undefined,
|
||||
): ExternalEntryTarget | null {
|
||||
if (!value) return null;
|
||||
const route = normalizeRoute(value);
|
||||
if (
|
||||
route === ROUTES.chat ||
|
||||
route === ROUTES.tip ||
|
||||
route === ROUTES.privateRoom
|
||||
) {
|
||||
return route;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveTargetAlias(
|
||||
function resolveTarget(
|
||||
value: string | null | undefined,
|
||||
): ExternalEntryTarget | null {
|
||||
if (!value) return null;
|
||||
const target = value.trim().toLowerCase();
|
||||
|
||||
if (target === "chat") return ROUTES.chat;
|
||||
if (target === "tip" || target === "tips") {
|
||||
return ROUTES.tip;
|
||||
}
|
||||
if (target === "private-room") {
|
||||
return ROUTES.privateRoom;
|
||||
}
|
||||
if (target === "tip") return ROUTES.tip;
|
||||
if (target === "private-room") return ROUTES.privateRoom;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeRoute(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("/") || trimmed.startsWith("//")) return "";
|
||||
return trimmed.split(/[?#]/, 1)[0] ?? "";
|
||||
}
|
||||
|
||||
function hasValue(value: string | null | undefined): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PayChannel } from "@/data/dto/payment";
|
||||
import type {
|
||||
PendingChatUnlockKind,
|
||||
PendingChatPromotion,
|
||||
PendingChatUnlockStage,
|
||||
} from "@/data/storage/navigation";
|
||||
|
||||
@@ -15,16 +16,24 @@ export interface OpenSubscriptionInput {
|
||||
}
|
||||
|
||||
export interface StartMessageUnlockInput {
|
||||
messageId: string;
|
||||
displayMessageId: string;
|
||||
messageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: PendingChatPromotion["lockType"];
|
||||
clientLockId?: string;
|
||||
promotion?: PendingChatPromotion;
|
||||
returnUrl: string;
|
||||
stage?: PendingChatUnlockStage;
|
||||
onAuthenticated: () => void;
|
||||
}
|
||||
|
||||
export interface OpenSubscriptionForPendingUnlockInput {
|
||||
messageId: string;
|
||||
displayMessageId: string;
|
||||
messageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: PendingChatPromotion["lockType"];
|
||||
clientLockId?: string;
|
||||
promotion?: PendingChatPromotion;
|
||||
returnUrl: string;
|
||||
type: AppSubscriptionType;
|
||||
payChannel?: PayChannel;
|
||||
|
||||
@@ -108,8 +108,12 @@ export function useAppNavigator(): AppNavigator {
|
||||
|
||||
const startMessageUnlock = useCallback(
|
||||
({
|
||||
displayMessageId,
|
||||
messageId,
|
||||
kind,
|
||||
lockType,
|
||||
clientLockId,
|
||||
promotion,
|
||||
returnUrl,
|
||||
stage = "auth",
|
||||
onAuthenticated,
|
||||
@@ -121,8 +125,12 @@ export function useAppNavigator(): AppNavigator {
|
||||
|
||||
void (async () => {
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
displayMessageId,
|
||||
messageId,
|
||||
kind,
|
||||
lockType,
|
||||
clientLockId,
|
||||
promotion,
|
||||
returnUrl,
|
||||
stage,
|
||||
});
|
||||
@@ -134,16 +142,24 @@ export function useAppNavigator(): AppNavigator {
|
||||
|
||||
const openSubscriptionForPendingUnlock = useCallback(
|
||||
({
|
||||
displayMessageId,
|
||||
messageId,
|
||||
kind,
|
||||
lockType,
|
||||
clientLockId,
|
||||
promotion,
|
||||
returnUrl,
|
||||
type,
|
||||
payChannel = getDefaultPayChannel(),
|
||||
}: OpenSubscriptionForPendingUnlockInput): void => {
|
||||
void (async () => {
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
displayMessageId,
|
||||
messageId,
|
||||
kind,
|
||||
lockType,
|
||||
clientLockId,
|
||||
promotion,
|
||||
returnUrl,
|
||||
stage: "payment",
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ function makeResponse(
|
||||
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
return {
|
||||
messages: [],
|
||||
promotion: null,
|
||||
isReplyingAI: true,
|
||||
pendingReplyCount: 1,
|
||||
upgradePromptVisible: false,
|
||||
@@ -56,6 +57,9 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
...overrides,
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
} from "@/data/dto/chat";
|
||||
import { chatMachine } from "@/stores/chat/chat-machine";
|
||||
import type { ChatEvent } from "@/stores/chat/chat-events";
|
||||
import type {
|
||||
UnlockMessageOutput as MachineUnlockMessageOutput,
|
||||
UnlockMessageRequest,
|
||||
} from "@/stores/chat/chat-machine.helpers";
|
||||
|
||||
interface LoadMoreHistoryOutput {
|
||||
messages: UiMessage[];
|
||||
@@ -29,7 +33,7 @@ interface UnlockHistoryOutput {
|
||||
newOffset: number;
|
||||
}
|
||||
|
||||
interface UnlockMessageOutput {
|
||||
interface TestUnlockMessageOutput {
|
||||
messageId: string;
|
||||
response: UnlockPrivateResponse;
|
||||
}
|
||||
@@ -81,7 +85,7 @@ function createTestChatMachine(
|
||||
options: {
|
||||
historyMessages?: UiMessage[];
|
||||
unlockHistoryOutput?: UnlockHistoryOutput;
|
||||
unlockMessageOutput?: UnlockMessageOutput;
|
||||
unlockMessageOutput?: TestUnlockMessageOutput;
|
||||
} = {},
|
||||
) {
|
||||
return chatMachine.provide({
|
||||
@@ -124,13 +128,20 @@ function createTestChatMachine(
|
||||
newOffset: 0,
|
||||
...options.unlockHistoryOutput,
|
||||
})),
|
||||
unlockMessage: fromPromise<UnlockMessageOutput, { messageId: string }>(
|
||||
async ({ input }) =>
|
||||
options.unlockMessageOutput ?? {
|
||||
messageId: input.messageId,
|
||||
response: makeUnlockPrivateResponse(),
|
||||
},
|
||||
),
|
||||
unlockMessage: fromPromise<
|
||||
MachineUnlockMessageOutput,
|
||||
UnlockMessageRequest
|
||||
>(async ({ input }) => {
|
||||
const output = options.unlockMessageOutput ?? {
|
||||
messageId: input.messageId ?? input.displayMessageId,
|
||||
response: makeUnlockPrivateResponse(),
|
||||
};
|
||||
return {
|
||||
displayMessageId: input.displayMessageId,
|
||||
request: input,
|
||||
response: output.response,
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -217,6 +228,45 @@ describe("chatMachine transitions", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("keeps a promotion separate from normal history", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "history-1",
|
||||
content: "Existing history",
|
||||
isFromAI: true,
|
||||
date: "2026-07-13",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
actor.send({
|
||||
type: "ChatPromotionInjected",
|
||||
promotion: {
|
||||
promotionType: "voice",
|
||||
lockType: "voice_message",
|
||||
clientLockId: "promotion-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(context.messages).toHaveLength(1);
|
||||
expect(context.promotion?.message).toMatchObject({
|
||||
id: "promotion:promotion-1",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
});
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("renders local history before network history sync finishes", async () => {
|
||||
let resolveNetwork!: () => void;
|
||||
const networkReleased = new Promise<void>((resolve) => {
|
||||
@@ -586,7 +636,7 @@ describe("chatMachine transitions", () => {
|
||||
unlockMessageOutput: {
|
||||
messageId: "msg-private-locked",
|
||||
response: makeUnlockPrivateResponse({
|
||||
content: "This response content must be ignored.",
|
||||
content: "Unlocked private message content.",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
@@ -612,7 +662,7 @@ describe("chatMachine transitions", () => {
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-private-locked",
|
||||
content: "Original private message content.",
|
||||
content: "Unlocked private message content.",
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
lockedPrivate: false,
|
||||
@@ -647,7 +697,7 @@ describe("chatMachine transitions", () => {
|
||||
unlockMessageOutput: {
|
||||
messageId: "msg-shared-id",
|
||||
response: makeUnlockPrivateResponse({
|
||||
content: "This response content must be ignored.",
|
||||
content: "Unlocked private AI message.",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
@@ -676,7 +726,7 @@ describe("chatMachine transitions", () => {
|
||||
},
|
||||
{
|
||||
id: "msg-shared-id",
|
||||
content: "Original AI private message",
|
||||
content: "Unlocked private AI message.",
|
||||
isFromAI: true,
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
@@ -848,6 +898,7 @@ describe("chatMachine transitions", () => {
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.unlockPaywallRequest).toEqual({
|
||||
displayMessageId: "msg-voice-locked",
|
||||
messageId: "msg-voice-locked",
|
||||
kind: "voice",
|
||||
reason: "insufficient_balance",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { UnlockPrivateResponse } from "@/data/dto/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
import {
|
||||
appendPromotionMessage,
|
||||
applyPromotionUnlockOutput,
|
||||
createChatPromotionState,
|
||||
} from "@/stores/chat/chat-promotion";
|
||||
|
||||
const promotion: PendingChatPromotion = {
|
||||
promotionType: "image",
|
||||
lockType: "image_paywall",
|
||||
clientLockId: "promotion-1",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
describe("chat promotion", () => {
|
||||
it("creates the requested locked message and appends it at the tail", () => {
|
||||
const state = createChatPromotionState(promotion);
|
||||
const messages = appendPromotionMessage(
|
||||
[
|
||||
{
|
||||
id: "history-1",
|
||||
content: "History",
|
||||
isFromAI: true,
|
||||
date: "2026-07-13",
|
||||
},
|
||||
],
|
||||
state,
|
||||
);
|
||||
|
||||
expect(messages.map((message) => message.id)).toEqual([
|
||||
"history-1",
|
||||
"promotion:promotion-1",
|
||||
]);
|
||||
expect(messages[1]).toMatchObject({
|
||||
locked: true,
|
||||
lockReason: "image_paywall",
|
||||
imagePaywalled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces a temporary promotion with the real unlocked image", () => {
|
||||
const state = createChatPromotionState(promotion);
|
||||
const next = applyPromotionUnlockOutput(state, {
|
||||
displayMessageId: "promotion:promotion-1",
|
||||
request: {
|
||||
displayMessageId: "promotion:promotion-1",
|
||||
lockType: "image_paywall",
|
||||
clientLockId: "promotion-1",
|
||||
},
|
||||
response: UnlockPrivateResponse.from({
|
||||
unlocked: true,
|
||||
messageId: "backend-1",
|
||||
image: {
|
||||
type: "promotion",
|
||||
url: "https://example.com/unlocked.jpg",
|
||||
},
|
||||
lockDetail: {
|
||||
locked: false,
|
||||
showContent: true,
|
||||
showUpgrade: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(next?.message).toMatchObject({
|
||||
id: "backend-1",
|
||||
imageUrl: "https://example.com/unlocked.jpg",
|
||||
imagePaywalled: false,
|
||||
locked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the promotion last and removes matching history duplicates", () => {
|
||||
const state = createChatPromotionState(promotion, "backend-1");
|
||||
const messages = appendPromotionMessage(
|
||||
[
|
||||
{
|
||||
id: "backend-1",
|
||||
content: "Stale history copy",
|
||||
isFromAI: true,
|
||||
date: "2026-07-13",
|
||||
},
|
||||
],
|
||||
state,
|
||||
);
|
||||
|
||||
expect(messages).toEqual([state.message]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { useMachine } from "@xstate/react";
|
||||
|
||||
import { chatMachine } from "./chat-machine";
|
||||
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
||||
import { appendPromotionMessage } from "./chat-promotion";
|
||||
|
||||
/**
|
||||
* 对外暴露的 State 形状
|
||||
@@ -20,6 +21,8 @@ import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
||||
*/
|
||||
interface ChatState {
|
||||
messages: MachineContext["messages"];
|
||||
historyMessages: MachineContext["messages"];
|
||||
promotion: MachineContext["promotion"];
|
||||
isReplyingAI: boolean;
|
||||
upgradePromptVisible: boolean;
|
||||
upgradeReason: MachineContext["upgradeReason"];
|
||||
@@ -51,7 +54,12 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
||||
// 映射 XState 状态机快照 → 原 ChatState 形状
|
||||
const chatState = useMemo<ChatState>(
|
||||
() => ({
|
||||
messages: state.context.messages,
|
||||
messages: appendPromotionMessage(
|
||||
state.context.messages,
|
||||
state.context.promotion,
|
||||
),
|
||||
historyMessages: state.context.messages,
|
||||
promotion: state.context.promotion,
|
||||
isReplyingAI: state.context.isReplyingAI,
|
||||
upgradePromptVisible: state.context.upgradePromptVisible,
|
||||
upgradeReason: state.context.upgradeReason,
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
* chat-screen 不直接读 AuthStorage(除 token 取值)。
|
||||
*/
|
||||
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
|
||||
export type ChatEvent =
|
||||
// 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
|
||||
@@ -35,10 +37,19 @@ export type ChatEvent =
|
||||
| { type: "ChatSendMessage"; content: string }
|
||||
| { type: "ChatSendImage"; imageBase64: string }
|
||||
| { type: "ChatLoadMoreHistory" }
|
||||
| {
|
||||
type: "ChatPromotionInjected";
|
||||
promotion: PendingChatPromotion;
|
||||
messageId?: string;
|
||||
}
|
||||
| { type: "ChatPromotionCleared" }
|
||||
| {
|
||||
type: "ChatUnlockMessageRequested";
|
||||
messageId: string;
|
||||
remoteMessageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: ChatLockType;
|
||||
clientLockId?: string;
|
||||
}
|
||||
| { type: "ChatUnlockPaywallNavigationConsumed" }
|
||||
| { type: "ChatPaymentSucceeded" }
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ChatState } from "./chat-state";
|
||||
export const PAGE_SIZE = 50;
|
||||
|
||||
export * from "./chat-message-mappers";
|
||||
export * from "./chat-promotion";
|
||||
export * from "./chat-send-state";
|
||||
export * from "./chat-unlock-helpers";
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
applyNetworkHistoryLoadedOutput,
|
||||
countLockedHistoryMessages,
|
||||
shouldPromptUnlockHistory,
|
||||
createChatPromotionState,
|
||||
} from "./chat-machine.helpers";
|
||||
import {
|
||||
loadHistoryActor,
|
||||
@@ -113,18 +114,32 @@ export const chatMachine = setup({
|
||||
clearUnlockPaywallRequest: clearUnlockPaywallRequestAction,
|
||||
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
|
||||
|
||||
startGuestSession: assign(() => ({
|
||||
startGuestSession: assign(({ context }) => ({
|
||||
...initialState,
|
||||
promotion: context.promotion,
|
||||
})),
|
||||
|
||||
startUserSession: assign(() => ({
|
||||
startUserSession: assign(({ context }) => ({
|
||||
...initialState,
|
||||
promotion: context.promotion,
|
||||
})),
|
||||
|
||||
clearChatSession: assign(() => ({
|
||||
...initialState,
|
||||
})),
|
||||
|
||||
injectPromotion: assign(({ event }) => {
|
||||
if (event.type !== "ChatPromotionInjected") return {};
|
||||
return {
|
||||
promotion: createChatPromotionState(
|
||||
event.promotion,
|
||||
event.messageId,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
clearPromotion: assign({ promotion: null }),
|
||||
|
||||
applyLocalHistoryLoaded: assign(({ event }) => {
|
||||
if (event.type !== "ChatLocalHistoryLoaded") return {};
|
||||
return applyHistoryLoadedOutput(event.output);
|
||||
@@ -169,6 +184,10 @@ export const chatMachine = setup({
|
||||
id: "chat",
|
||||
initial: "idle",
|
||||
context: initialState,
|
||||
on: {
|
||||
ChatPromotionInjected: { actions: "injectPromotion" },
|
||||
ChatPromotionCleared: { actions: "clearPromotion" },
|
||||
},
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
@@ -471,7 +490,16 @@ export const chatMachine = setup({
|
||||
id: "unlockMessage",
|
||||
src: "unlockMessage",
|
||||
input: ({ context }) => ({
|
||||
messageId: context.unlockingMessageId ?? "",
|
||||
displayMessageId: context.unlockingMessageId ?? "",
|
||||
...(context.unlockingRemoteMessageId
|
||||
? { messageId: context.unlockingRemoteMessageId }
|
||||
: {}),
|
||||
...(context.unlockingLockType
|
||||
? { lockType: context.unlockingLockType }
|
||||
: {}),
|
||||
...(context.unlockingClientLockId
|
||||
? { clientLockId: context.unlockingClientLockId }
|
||||
: {}),
|
||||
}),
|
||||
onDone: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
import { todayString } from "@/utils";
|
||||
|
||||
import {
|
||||
applySingleUnlockOutput,
|
||||
type UnlockMessageOutput,
|
||||
} from "./chat-unlock-helpers";
|
||||
|
||||
const PROMOTION_COPY = {
|
||||
voice: "I left a voice message for you. Unlock it to listen.",
|
||||
image: "I sent you a private photo. Unlock it to view.",
|
||||
private: "I have something private to tell you. Unlock it to read.",
|
||||
} as const;
|
||||
|
||||
export interface ChatPromotionState {
|
||||
session: PendingChatPromotion;
|
||||
message: UiMessage;
|
||||
}
|
||||
|
||||
export function createChatPromotionState(
|
||||
session: PendingChatPromotion,
|
||||
messageId?: string,
|
||||
): ChatPromotionState {
|
||||
const isImage = session.promotionType === "image";
|
||||
const isPrivate = session.promotionType === "private";
|
||||
|
||||
return {
|
||||
session,
|
||||
message: {
|
||||
id: messageId || `promotion:${session.clientLockId}`,
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
locked: true,
|
||||
lockReason: session.lockType,
|
||||
imagePaywalled: isImage ? true : undefined,
|
||||
lockedPrivate: isPrivate ? true : undefined,
|
||||
privateMessageHint: PROMOTION_COPY[session.promotionType],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function appendPromotionMessage(
|
||||
messages: readonly UiMessage[],
|
||||
promotion: ChatPromotionState | null,
|
||||
): UiMessage[] {
|
||||
if (!promotion) return [...messages];
|
||||
return [
|
||||
...messages.filter((message) => message.id !== promotion.message.id),
|
||||
promotion.message,
|
||||
];
|
||||
}
|
||||
|
||||
export function applyPromotionUnlockOutput(
|
||||
promotion: ChatPromotionState | null,
|
||||
output: UnlockMessageOutput,
|
||||
): ChatPromotionState | null {
|
||||
if (!promotion || promotion.message.id !== output.displayMessageId) {
|
||||
return promotion;
|
||||
}
|
||||
return {
|
||||
...promotion,
|
||||
message:
|
||||
applySingleUnlockOutput([promotion.message], output)[0] ??
|
||||
promotion.message,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
import type { ChatPromotionState } from "./chat-promotion";
|
||||
|
||||
export type ChatUpgradeReason = "insufficient_credits";
|
||||
|
||||
export interface ChatUnlockPaywallRequest {
|
||||
messageId: string;
|
||||
displayMessageId: string;
|
||||
messageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: ChatLockType;
|
||||
clientLockId?: string;
|
||||
promotion?: PendingChatPromotion;
|
||||
reason: string;
|
||||
creditBalance: number;
|
||||
requiredCredits: number;
|
||||
@@ -14,6 +21,7 @@ export interface ChatUnlockPaywallRequest {
|
||||
|
||||
export interface ChatState {
|
||||
messages: UiMessage[];
|
||||
promotion: ChatPromotionState | null;
|
||||
isReplyingAI: boolean;
|
||||
pendingReplyCount: number;
|
||||
upgradePromptVisible: boolean;
|
||||
@@ -40,12 +48,16 @@ export interface ChatState {
|
||||
isUnlockingMessage: boolean;
|
||||
unlockingMessageId: string | null;
|
||||
unlockingMessageKind: PendingChatUnlockKind | null;
|
||||
unlockingRemoteMessageId: string | null;
|
||||
unlockingLockType: ChatLockType | null;
|
||||
unlockingClientLockId: string | null;
|
||||
unlockMessageError: string | null;
|
||||
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
|
||||
}
|
||||
|
||||
export const initialState: ChatState = {
|
||||
messages: [],
|
||||
promotion: null,
|
||||
isReplyingAI: false,
|
||||
pendingReplyCount: 0,
|
||||
upgradePromptVisible: false,
|
||||
@@ -68,6 +80,9 @@ export const initialState: ChatState = {
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
import { readAndSyncHistory } from "./chat-history-sync";
|
||||
import {
|
||||
applySingleUnlockOutput,
|
||||
applyPromotionUnlockOutput,
|
||||
countLockedHistoryMessages,
|
||||
type UnlockMessageRequest,
|
||||
type UnlockMessageOutput,
|
||||
} from "./chat-machine.helpers";
|
||||
|
||||
@@ -49,24 +51,32 @@ export const unlockHistoryActor = fromPromise<{
|
||||
|
||||
export const unlockMessageActor = fromPromise<
|
||||
UnlockMessageOutput,
|
||||
{ messageId: string }
|
||||
UnlockMessageRequest
|
||||
>(async ({ input }) => {
|
||||
const chatRepo = getChatRepository();
|
||||
const unlockResult = await chatRepo.unlockPrivateMessage(input.messageId);
|
||||
const unlockResult = await chatRepo.unlockPrivateMessage({
|
||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||
...(input.lockType ? { lockType: input.lockType } : {}),
|
||||
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
||||
});
|
||||
if (Result.isErr(unlockResult)) {
|
||||
log.error("[chat-machine] unlockMessageActor failed", {
|
||||
messageId: input.messageId,
|
||||
clientLockId: input.clientLockId,
|
||||
error: unlockResult.error,
|
||||
});
|
||||
throw unlockResult.error;
|
||||
}
|
||||
|
||||
if (unlockResult.data.unlocked) {
|
||||
if (unlockResult.data.unlocked && input.messageId && !input.clientLockId) {
|
||||
const markResult = await chatRepo.markPrivateMessageUnlockedInLocal(
|
||||
input.messageId,
|
||||
{
|
||||
content: unlockResult.data.content,
|
||||
audioUrl: unlockResult.data.audioUrl,
|
||||
...(unlockResult.data.image.url
|
||||
? { image: unlockResult.data.image }
|
||||
: {}),
|
||||
lockDetail: unlockResult.data.lockDetail,
|
||||
},
|
||||
);
|
||||
@@ -79,7 +89,8 @@ export const unlockMessageActor = fromPromise<
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: input.messageId,
|
||||
displayMessageId: input.displayMessageId,
|
||||
request: input,
|
||||
response: unlockResult.data,
|
||||
};
|
||||
});
|
||||
@@ -124,6 +135,10 @@ export const markUnlockMessageStartedAction = chatAssign(
|
||||
isUnlockingMessage: true,
|
||||
unlockingMessageId: event.messageId,
|
||||
unlockingMessageKind: event.kind,
|
||||
unlockingRemoteMessageId:
|
||||
event.remoteMessageId ?? (event.lockType ? null : event.messageId),
|
||||
unlockingLockType: event.lockType ?? null,
|
||||
unlockingClientLockId: event.clientLockId ?? null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
};
|
||||
@@ -136,9 +151,13 @@ export const applyUnlockMessageSucceededAction = chatAssign(
|
||||
if (!output) return {};
|
||||
return {
|
||||
messages: applySingleUnlockOutput(context.messages, output),
|
||||
promotion: applyPromotionUnlockOutput(context.promotion, output),
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
creditBalance: output.response.creditBalance,
|
||||
@@ -154,13 +173,33 @@ export const requestUnlockPaymentFromOutputAction = chatAssign(
|
||||
const output = (event as { output?: UnlockMessageOutput }).output;
|
||||
if (!output) return {};
|
||||
return {
|
||||
promotion: applyPromotionUnlockOutput(context.promotion, output),
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: output.response.reason,
|
||||
unlockPaywallRequest: {
|
||||
messageId: output.messageId,
|
||||
displayMessageId:
|
||||
output.response.messageId || output.displayMessageId,
|
||||
...(output.response.messageId || output.request.messageId
|
||||
? {
|
||||
messageId:
|
||||
output.response.messageId || output.request.messageId,
|
||||
}
|
||||
: {}),
|
||||
kind: context.unlockingMessageKind ?? "private",
|
||||
...(output.request.lockType
|
||||
? { lockType: output.request.lockType }
|
||||
: {}),
|
||||
...(output.request.clientLockId
|
||||
? { clientLockId: output.request.clientLockId }
|
||||
: {}),
|
||||
...(context.promotion?.message.id === output.displayMessageId
|
||||
? { promotion: context.promotion.session }
|
||||
: {}),
|
||||
reason: output.response.reason,
|
||||
creditBalance: output.response.creditBalance,
|
||||
requiredCredits: output.response.requiredCredits,
|
||||
@@ -180,8 +219,20 @@ export const requestUnlockPaymentFromErrorAction = chatAssign(
|
||||
unlockPaywallRequest:
|
||||
context.unlockingMessageId && context.unlockingMessageKind
|
||||
? {
|
||||
messageId: context.unlockingMessageId,
|
||||
displayMessageId: context.unlockingMessageId,
|
||||
...(context.unlockingRemoteMessageId
|
||||
? { messageId: context.unlockingRemoteMessageId }
|
||||
: {}),
|
||||
kind: context.unlockingMessageKind,
|
||||
...(context.unlockingLockType
|
||||
? { lockType: context.unlockingLockType }
|
||||
: {}),
|
||||
...(context.unlockingClientLockId
|
||||
? { clientLockId: context.unlockingClientLockId }
|
||||
: {}),
|
||||
...(context.promotion?.message.id === context.unlockingMessageId
|
||||
? { promotion: context.promotion.session }
|
||||
: {}),
|
||||
reason: "unlock_failed",
|
||||
creditBalance: context.creditBalance,
|
||||
requiredCredits: context.requiredCredits,
|
||||
@@ -190,6 +241,9 @@ export const requestUnlockPaymentFromErrorAction = chatAssign(
|
||||
: null,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { UiMessage, UnlockPrivateResponse } from "@/data/dto/chat";
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
|
||||
export interface UnlockMessageRequest {
|
||||
displayMessageId: string;
|
||||
messageId?: string;
|
||||
lockType?: ChatLockType;
|
||||
clientLockId?: string;
|
||||
}
|
||||
|
||||
export interface UnlockMessageOutput {
|
||||
messageId: string;
|
||||
displayMessageId: string;
|
||||
request: UnlockMessageRequest;
|
||||
response: UnlockPrivateResponse;
|
||||
}
|
||||
|
||||
@@ -13,17 +22,35 @@ export function applySingleUnlockOutput(
|
||||
messages: readonly UiMessage[],
|
||||
output: UnlockMessageOutput,
|
||||
): UiMessage[] {
|
||||
if (!output.response.unlocked) return [...messages];
|
||||
|
||||
return messages.map((message) => {
|
||||
if (!shouldApplySingleUnlock(message, output.messageId)) return message;
|
||||
if (!shouldApplySingleUnlock(message, output.displayMessageId)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const resolvedId = output.response.messageId || message.id;
|
||||
if (!output.response.unlocked) {
|
||||
return {
|
||||
...message,
|
||||
id: resolvedId,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedImageUrl =
|
||||
output.response.image.url ?? message.imageUrl;
|
||||
const resolvedContent =
|
||||
message.lockReason === "private_message"
|
||||
? output.response.content || message.content
|
||||
: message.content;
|
||||
|
||||
return {
|
||||
...message,
|
||||
id: resolvedId,
|
||||
content: resolvedContent,
|
||||
audioUrl: getUnlockedAudioUrl(message, output.response),
|
||||
imageUrl: resolvedImageUrl,
|
||||
locked: output.response.lockDetail.locked,
|
||||
lockReason: output.response.lockDetail.reason,
|
||||
imagePaywalled: message.imageUrl
|
||||
imagePaywalled: resolvedImageUrl
|
||||
? output.response.lockDetail.locked &&
|
||||
output.response.lockDetail.showUpgrade
|
||||
: undefined,
|
||||
@@ -48,6 +75,8 @@ function shouldApplySingleUnlock(message: UiMessage, messageId: string): boolean
|
||||
if (message.locked !== true) return false;
|
||||
return (
|
||||
message.imagePaywalled === true ||
|
||||
message.lockReason === "image_paywall" ||
|
||||
message.lockReason === "image" ||
|
||||
message.lockReason === "private_message" ||
|
||||
message.lockReason === "voice_message"
|
||||
);
|
||||
@@ -61,6 +90,8 @@ function isUnlockableLockedMessage(message: UiMessage): boolean {
|
||||
if (!message.isFromAI || message.locked !== true) return false;
|
||||
if (message.imagePaywalled === true) return true;
|
||||
return (
|
||||
message.lockReason === "image_paywall" ||
|
||||
message.lockReason === "image" ||
|
||||
message.lockReason === "private_message" ||
|
||||
message.lockReason === "voice_message"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user