feat(chat): add promotional external entry flow

This commit is contained in:
2026-07-13 12:43:18 +08:00
parent e5ee9940ca
commit 3752b3b729
44 changed files with 1623 additions and 190 deletions
+62 -5
View File
@@ -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=""');
});
});
+5
View File
@@ -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,
]);
+12 -18
View File
@@ -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)),
);
}
+3 -2
View File
@@ -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",
+122 -14
View File
@@ -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("-");
}
+1
View File
@@ -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",
+2 -2
View File
@@ -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",
+23 -1
View File
@@ -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();
}
+27 -38
View File
@@ -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;
}
+11 -2
View File
@@ -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;
+16
View File
@@ -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]);
});
});
+9 -1
View File
@@ -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,
+11
View File
@@ -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" }
+1
View File
@@ -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";
+31 -3
View File
@@ -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: [
{
+68
View File
@@ -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,
};
}
+16 -1
View File
@@ -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,
};
+60 -6
View File
@@ -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,
}),
);
+36 -5
View File
@@ -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"
);