feat(chat): add private message unlock flow
This commit is contained in:
@@ -3,8 +3,8 @@ NEXT_PUBLIC_APP_ENV=test
|
|||||||
# NextAuth v4 —— OAuth callback **公**网 base URL(**必**须与**公**网域名**一**致)
|
# NextAuth v4 —— OAuth callback **公**网 base URL(**必**须与**公**网域名**一**致)
|
||||||
NEXTAUTH_URL=https://frontend-test.banlv-ai.com
|
NEXTAUTH_URL=https://frontend-test.banlv-ai.com
|
||||||
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
||||||
NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com
|
NEXT_PUBLIC_API_BASE_URL=https://testapi.banlv-ai.com
|
||||||
NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws
|
NEXT_PUBLIC_WS_BASE_URL=wss://testapi.banlv-ai.com/ws
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
|
||||||
|
|
||||||
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { LoginStatus } from "@/data/dto/auth";
|
|||||||
import { AppStorage } from "@/data/storage/app/app_storage";
|
import { AppStorage } from "@/data/storage/app/app_storage";
|
||||||
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||||
import { useChatState } from "@/stores/chat/chat-context";
|
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
||||||
import { AppEnvUtil, BrowserDetector, todayString, UrlLauncherUtil } from "@/utils";
|
import { AppEnvUtil, BrowserDetector, todayString, UrlLauncherUtil } from "@/utils";
|
||||||
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
|
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ function deriveIsGuest(loginStatus: LoginStatus): boolean {
|
|||||||
|
|
||||||
export function ChatScreen() {
|
export function ChatScreen() {
|
||||||
const state = useChatState();
|
const state = useChatState();
|
||||||
|
const chatDispatch = useChatDispatch();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
|
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@@ -48,8 +49,16 @@ export function ChatScreen() {
|
|||||||
state.paywallReason === "daily_limit";
|
state.paywallReason === "daily_limit";
|
||||||
const showPhotoPaywallBanner =
|
const showPhotoPaywallBanner =
|
||||||
state.paywallTriggered && state.paywallReason === "photo_paywall";
|
state.paywallTriggered && state.paywallReason === "photo_paywall";
|
||||||
const showExhaustedBanner =
|
const showPrivatePaywallBanner =
|
||||||
showDailyLimitBanner || showPhotoPaywallBanner;
|
state.paywallTriggered && state.paywallReason === "private_paywall";
|
||||||
|
const showInlineUpgradeBanner =
|
||||||
|
showPhotoPaywallBanner || showPrivatePaywallBanner;
|
||||||
|
const inlineUpgradeTitle = showPrivatePaywallBanner
|
||||||
|
? "Unlock VIP to view private messages"
|
||||||
|
: "Unlock VIP to view Elio's photos";
|
||||||
|
const inlineUpgradeCta = showPrivatePaywallBanner
|
||||||
|
? "Activate VIP to view private messages"
|
||||||
|
: "Activate VIP to view photos";
|
||||||
|
|
||||||
const externalBrowserPromptShownRef = useRef(false);
|
const externalBrowserPromptShownRef = useRef(false);
|
||||||
|
|
||||||
@@ -122,6 +131,10 @@ export function ChatScreen() {
|
|||||||
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash);
|
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleUnlockPrivateMessage(messageId: string): void {
|
||||||
|
chatDispatch({ type: "ChatUnlockPrivateMessage", messageId });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell>
|
<MobileShell>
|
||||||
<div className={styles.shell}>
|
<div className={styles.shell}>
|
||||||
@@ -144,20 +157,19 @@ export function ChatScreen() {
|
|||||||
messages={state.messages}
|
messages={state.messages}
|
||||||
isReplyingAI={state.isReplyingAI}
|
isReplyingAI={state.isReplyingAI}
|
||||||
isGuest={isGuest}
|
isGuest={isGuest}
|
||||||
|
unlockingPrivateMessageId={state.unlockingPrivateMessageId}
|
||||||
|
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showExhaustedBanner ? (
|
{showInlineUpgradeBanner ? (
|
||||||
|
<ChatQuotaExhaustedBanner
|
||||||
|
title={inlineUpgradeTitle}
|
||||||
|
ctaLabel={inlineUpgradeCta}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showDailyLimitBanner ? (
|
||||||
<ChatQuotaExhaustedBanner
|
<ChatQuotaExhaustedBanner
|
||||||
title={
|
|
||||||
showPhotoPaywallBanner
|
|
||||||
? "Unlock VIP to view Elio's photos"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
ctaLabel={
|
|
||||||
showPhotoPaywallBanner
|
|
||||||
? "Activate VIP to view photos"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ChatInputBar disabled={state.isReplyingAI} />
|
<ChatInputBar disabled={state.isReplyingAI} />
|
||||||
|
|||||||
@@ -28,9 +28,16 @@ export interface ChatAreaProps {
|
|||||||
messages: readonly UiMessage[];
|
messages: readonly UiMessage[];
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
|
unlockingPrivateMessageId?: string | null;
|
||||||
|
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatArea({ messages, isReplyingAI }: ChatAreaProps) {
|
export function ChatArea({
|
||||||
|
messages,
|
||||||
|
isReplyingAI,
|
||||||
|
unlockingPrivateMessageId,
|
||||||
|
onUnlockPrivateMessage,
|
||||||
|
}: ChatAreaProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const prevLengthRef = useRef(messages.length);
|
const prevLengthRef = useRef(messages.length);
|
||||||
|
|
||||||
@@ -49,7 +56,11 @@ export function ChatArea({ messages, isReplyingAI }: ChatAreaProps) {
|
|||||||
<main ref={scrollRef} className={styles.area} aria-label="Chat messages">
|
<main ref={scrollRef} className={styles.area} aria-label="Chat messages">
|
||||||
<AiDisclosureBanner />
|
<AiDisclosureBanner />
|
||||||
|
|
||||||
{renderMessagesWithDateHeaders(messages)}
|
{renderMessagesWithDateHeaders(
|
||||||
|
messages,
|
||||||
|
unlockingPrivateMessageId,
|
||||||
|
onUnlockPrivateMessage,
|
||||||
|
)}
|
||||||
|
|
||||||
{isReplyingAI && <LottieMessageBubble />}
|
{isReplyingAI && <LottieMessageBubble />}
|
||||||
</main>
|
</main>
|
||||||
@@ -57,7 +68,11 @@ export function ChatArea({ messages, isReplyingAI }: ChatAreaProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 渲染消息列表(按日期分组插入分隔条) */
|
/** 渲染消息列表(按日期分组插入分隔条) */
|
||||||
function renderMessagesWithDateHeaders(messages: readonly UiMessage[]) {
|
function renderMessagesWithDateHeaders(
|
||||||
|
messages: readonly UiMessage[],
|
||||||
|
unlockingPrivateMessageId?: string | null,
|
||||||
|
onUnlockPrivateMessage?: (messageId: string) => void,
|
||||||
|
) {
|
||||||
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
|
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
|
||||||
|
|
||||||
messages.forEach((m, i) => {
|
messages.forEach((m, i) => {
|
||||||
@@ -73,9 +88,17 @@ function renderMessagesWithDateHeaders(messages: readonly UiMessage[]) {
|
|||||||
) : (
|
) : (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
key={item.key}
|
key={item.key}
|
||||||
|
id={item.message.id}
|
||||||
content={item.message.content}
|
content={item.message.content}
|
||||||
imageUrl={item.message.imageUrl}
|
imageUrl={item.message.imageUrl}
|
||||||
isFromAI={item.message.isFromAI}
|
isFromAI={item.message.isFromAI}
|
||||||
|
privateLocked={item.message.privateLocked}
|
||||||
|
privateHint={item.message.privateHint}
|
||||||
|
isUnlockingPrivate={
|
||||||
|
item.message.id != null &&
|
||||||
|
item.message.id === unlockingPrivateMessageId
|
||||||
|
}
|
||||||
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,5 +22,6 @@ export * from "./message-bubble";
|
|||||||
export * from "./message-content";
|
export * from "./message-content";
|
||||||
export * from "./pwa-install-dialog";
|
export * from "./pwa-install-dialog";
|
||||||
export * from "./pwa-install-overlay";
|
export * from "./pwa-install-overlay";
|
||||||
|
export * from "./private-message-card";
|
||||||
export * from "./text-bubble";
|
export * from "./text-bubble";
|
||||||
export * from "./user-message-avatar";
|
export * from "./user-message-avatar";
|
||||||
|
|||||||
@@ -15,12 +15,26 @@ import { MessageContent } from "./message-content";
|
|||||||
import styles from "./chat-area.module.css";
|
import styles from "./chat-area.module.css";
|
||||||
|
|
||||||
export interface MessageBubbleProps {
|
export interface MessageBubbleProps {
|
||||||
|
id?: string;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
isFromAI: boolean;
|
isFromAI: boolean;
|
||||||
|
privateLocked?: boolean | null;
|
||||||
|
privateHint?: string | null;
|
||||||
|
isUnlockingPrivate?: boolean;
|
||||||
|
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProps) {
|
export function MessageBubble({
|
||||||
|
id,
|
||||||
|
content,
|
||||||
|
imageUrl,
|
||||||
|
isFromAI,
|
||||||
|
privateLocked,
|
||||||
|
privateHint,
|
||||||
|
isUnlockingPrivate,
|
||||||
|
onUnlockPrivateMessage,
|
||||||
|
}: MessageBubbleProps) {
|
||||||
const { avatarUrl } = useUserState();
|
const { avatarUrl } = useUserState();
|
||||||
|
|
||||||
if (isFromAI) {
|
if (isFromAI) {
|
||||||
@@ -29,9 +43,14 @@ export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProp
|
|||||||
<MessageAvatar isFromAI={true} />
|
<MessageAvatar isFromAI={true} />
|
||||||
<div style={{ width: 8 }} />
|
<div style={{ width: 8 }} />
|
||||||
<MessageContent
|
<MessageContent
|
||||||
|
messageId={id}
|
||||||
content={content}
|
content={content}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
isFromAI={true}
|
isFromAI={true}
|
||||||
|
privateLocked={privateLocked}
|
||||||
|
privateHint={privateHint}
|
||||||
|
isUnlockingPrivate={isUnlockingPrivate}
|
||||||
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
/>
|
/>
|
||||||
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
|
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
|
||||||
</div>
|
</div>
|
||||||
@@ -41,7 +60,16 @@ export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProp
|
|||||||
return (
|
return (
|
||||||
<div className={styles.bubbleRowUser} aria-label="User message">
|
<div className={styles.bubbleRowUser} aria-label="User message">
|
||||||
<div style={{ width: 43 }} /> {/* 占位 */}
|
<div style={{ width: 43 }} /> {/* 占位 */}
|
||||||
<MessageContent content={content} imageUrl={imageUrl} isFromAI={false} />
|
<MessageContent
|
||||||
|
messageId={id}
|
||||||
|
content={content}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
isFromAI={false}
|
||||||
|
privateLocked={privateLocked}
|
||||||
|
privateHint={privateHint}
|
||||||
|
isUnlockingPrivate={isUnlockingPrivate}
|
||||||
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
|
/>
|
||||||
<div style={{ width: 8 }} />
|
<div style={{ width: 8 }} />
|
||||||
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
|
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,19 +10,35 @@
|
|||||||
* - 两者都有:图片在上,文字在下
|
* - 两者都有:图片在上,文字在下
|
||||||
*/
|
*/
|
||||||
import { ImageBubble } from "./image-bubble";
|
import { ImageBubble } from "./image-bubble";
|
||||||
|
import { PrivateMessageCard } from "./private-message-card";
|
||||||
import { TextBubble } from "./text-bubble";
|
import { TextBubble } from "./text-bubble";
|
||||||
|
|
||||||
export interface MessageContentProps {
|
export interface MessageContentProps {
|
||||||
|
messageId?: string;
|
||||||
content: string;
|
content: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
isFromAI: boolean;
|
isFromAI: boolean;
|
||||||
|
privateLocked?: boolean | null;
|
||||||
|
privateHint?: string | null;
|
||||||
|
isUnlockingPrivate?: boolean;
|
||||||
|
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_PLACEHOLDER = "[图片]";
|
const IMAGE_PLACEHOLDER = "[图片]";
|
||||||
|
|
||||||
export function MessageContent({ content, imageUrl, isFromAI }: MessageContentProps) {
|
export function MessageContent({
|
||||||
|
messageId,
|
||||||
|
content,
|
||||||
|
imageUrl,
|
||||||
|
isFromAI,
|
||||||
|
privateLocked,
|
||||||
|
privateHint,
|
||||||
|
isUnlockingPrivate = false,
|
||||||
|
onUnlockPrivateMessage,
|
||||||
|
}: MessageContentProps) {
|
||||||
const hasImage = imageUrl != null && imageUrl.length > 0;
|
const hasImage = imageUrl != null && imageUrl.length > 0;
|
||||||
const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER;
|
const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER;
|
||||||
|
const isLockedPrivateMessage = privateLocked === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -37,8 +53,22 @@ export function MessageContent({ content, imageUrl, isFromAI }: MessageContentPr
|
|||||||
gap: 8,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasImage && imageUrl && <ImageBubble imageUrl={imageUrl} />}
|
{isLockedPrivateMessage ? (
|
||||||
{hasText && <TextBubble content={content} isFromAI={isFromAI} />}
|
<PrivateMessageCard
|
||||||
|
hint={privateHint}
|
||||||
|
isUnlocking={isUnlockingPrivate}
|
||||||
|
onUnlock={
|
||||||
|
messageId
|
||||||
|
? () => onUnlockPrivateMessage?.(messageId)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{hasImage && imageUrl && <ImageBubble imageUrl={imageUrl} />}
|
||||||
|
{hasText && <TextBubble content={content} isFromAI={isFromAI} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
.card {
|
||||||
|
max-width: min(280px, 100%);
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba(246, 87, 160, 0.2);
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 244, 248, 0.95), rgba(255, 255, 255, 0.95)),
|
||||||
|
#ffffff;
|
||||||
|
box-shadow: 0 4px 12px rgba(246, 87, 160, 0.12);
|
||||||
|
color: #3c3b3b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconWrap {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #ff8fc7 0%, #f657a0 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
color: #3c3b3b;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #ff67e0, #ff52a2);
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:focus-visible {
|
||||||
|
outline: 2px solid #f657a0;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LockKeyhole } from "lucide-react";
|
||||||
|
|
||||||
|
import styles from "./private-message-card.module.css";
|
||||||
|
|
||||||
|
export interface PrivateMessageCardProps {
|
||||||
|
hint?: string | null;
|
||||||
|
isUnlocking?: boolean;
|
||||||
|
onUnlock?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrivateMessageCard({
|
||||||
|
hint,
|
||||||
|
isUnlocking = false,
|
||||||
|
onUnlock,
|
||||||
|
}: PrivateMessageCardProps) {
|
||||||
|
return (
|
||||||
|
<div className={styles.card} role="group" aria-label="Locked private message">
|
||||||
|
<div className={styles.iconWrap} aria-hidden="true">
|
||||||
|
<LockKeyhole className={styles.icon} size={22} />
|
||||||
|
</div>
|
||||||
|
<p className={styles.hint}>
|
||||||
|
{hint && hint.length > 0
|
||||||
|
? hint
|
||||||
|
: "Elio has a private message for you."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.button}
|
||||||
|
disabled={isUnlocking || !onUnlock}
|
||||||
|
onClick={onUnlock}
|
||||||
|
>
|
||||||
|
{isUnlocking ? "Unlocking..." : "Unlock private message"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const UiMessageSchema = z.object({
|
export const UiMessageSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
content: z.string(),
|
content: z.string(),
|
||||||
isFromAI: z.boolean(),
|
isFromAI: z.boolean(),
|
||||||
/** 日期分隔条使用的本地日期(YYYY-MM-DD) */
|
/** 日期分隔条使用的本地日期(YYYY-MM-DD) */
|
||||||
@@ -16,6 +17,9 @@ export const UiMessageSchema = z.object({
|
|||||||
imageUrl: z.string().optional(),
|
imageUrl: z.string().optional(),
|
||||||
/** 语音 URL */
|
/** 语音 URL */
|
||||||
voiceUrl: z.string().optional(),
|
voiceUrl: z.string().optional(),
|
||||||
|
isPrivate: z.boolean().nullable().optional(),
|
||||||
|
privateLocked: z.boolean().nullable().optional(),
|
||||||
|
privateHint: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UiMessage = z.infer<typeof UiMessageSchema>;
|
export type UiMessage = z.infer<typeof UiMessageSchema>;
|
||||||
|
|||||||
@@ -68,6 +68,39 @@ export class ChatRepository implements IChatRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 把本地缓存中的私密消息标记为已解锁。 */
|
||||||
|
async markPrivateMessageUnlockedInLocal(
|
||||||
|
messageId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<Result<void>> {
|
||||||
|
return Result.wrap(async () => {
|
||||||
|
const localResult = await this.getLocalMessages();
|
||||||
|
if (Result.isErr(localResult)) {
|
||||||
|
throw localResult.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const updatedMessages = localResult.data.map((message) => {
|
||||||
|
if (message.id !== messageId) return message;
|
||||||
|
changed = true;
|
||||||
|
return ChatMessage.from({
|
||||||
|
...message.toJson(),
|
||||||
|
content,
|
||||||
|
isPrivate: message.isPrivate ?? true,
|
||||||
|
privateLocked: false,
|
||||||
|
privateHint: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!changed) return;
|
||||||
|
|
||||||
|
const saveResult = await this.saveMessagesToLocal(updatedMessages);
|
||||||
|
if (Result.isErr(saveResult)) {
|
||||||
|
throw saveResult.error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============ 本地(Dexie)操作 ============
|
// ============ 本地(Dexie)操作 ============
|
||||||
|
|
||||||
/** 把一条消息写入本地存储。 */
|
/** 把一条消息写入本地存储。 */
|
||||||
@@ -125,6 +158,9 @@ export class ChatRepository implements IChatRepository {
|
|||||||
content: message.content,
|
content: message.content,
|
||||||
createdAt: message.createdAt,
|
createdAt: message.createdAt,
|
||||||
imageUrl: message.imageUrl,
|
imageUrl: message.imageUrl,
|
||||||
|
isPrivate: message.isPrivate,
|
||||||
|
privateLocked: message.privateLocked,
|
||||||
|
privateHint: message.privateHint,
|
||||||
sessionId: "",
|
sessionId: "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -137,6 +173,9 @@ export class ChatRepository implements IChatRepository {
|
|||||||
content: local.content,
|
content: local.content,
|
||||||
createdAt: local.createdAt,
|
createdAt: local.createdAt,
|
||||||
imageUrl: local.imageUrl,
|
imageUrl: local.imageUrl,
|
||||||
|
isPrivate: local.isPrivate,
|
||||||
|
privateLocked: local.privateLocked,
|
||||||
|
privateHint: local.privateHint,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export interface IChatRepository {
|
|||||||
/** 解锁私密消息。 */
|
/** 解锁私密消息。 */
|
||||||
unlockPrivateMessage(messageId: string): Promise<Result<UnlockPrivateResponse>>;
|
unlockPrivateMessage(messageId: string): Promise<Result<UnlockPrivateResponse>>;
|
||||||
|
|
||||||
|
/** 把本地缓存中的私密消息标记为已解锁。 */
|
||||||
|
markPrivateMessageUnlockedInLocal(
|
||||||
|
messageId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<Result<void>>;
|
||||||
|
|
||||||
/** 把一条消息写入本地存储。 */
|
/** 把一条消息写入本地存储。 */
|
||||||
saveMessageToLocal(message: ChatMessage): Promise<Result<void>>;
|
saveMessageToLocal(message: ChatMessage): Promise<Result<void>>;
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ export const UnlockPrivateReasonSchema = z.enum([
|
|||||||
export const UnlockPrivateResponseSchema = z.object({
|
export const UnlockPrivateResponseSchema = z.object({
|
||||||
unlocked: z.boolean(),
|
unlocked: z.boolean(),
|
||||||
content: z.string().nullable(),
|
content: z.string().nullable(),
|
||||||
showUpgrade: z.boolean(),
|
showUpgrade: z.boolean().default(false),
|
||||||
paywallTriggered: z.boolean(),
|
paywallTriggered: z.boolean().default(false),
|
||||||
privateFreeLimit: z.number(),
|
privateFreeLimit: z.number().default(0),
|
||||||
privateUsedToday: z.number(),
|
privateUsedToday: z.number().default(0),
|
||||||
reason: UnlockPrivateReasonSchema,
|
reason: UnlockPrivateReasonSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export interface LocalMessageRow {
|
|||||||
content: string;
|
content: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
|
isPrivate?: boolean | null;
|
||||||
|
privateLocked?: boolean | null;
|
||||||
|
privateHint?: string | null;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export const LocalMessageSchema = z.object({
|
|||||||
content: z.string(),
|
content: z.string(),
|
||||||
createdAt: z.string().default(""),
|
createdAt: z.string().default(""),
|
||||||
imageUrl: z.string().nullable().default(null),
|
imageUrl: z.string().nullable().default(null),
|
||||||
|
isPrivate: z.boolean().nullable().default(null),
|
||||||
|
privateLocked: z.boolean().nullable().default(null),
|
||||||
|
privateHint: z.string().nullable().default(null),
|
||||||
sessionId: z.string().default(""),
|
sessionId: z.string().default(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,6 +38,9 @@ export class LocalMessage {
|
|||||||
declare readonly content: string;
|
declare readonly content: string;
|
||||||
declare readonly createdAt: string;
|
declare readonly createdAt: string;
|
||||||
declare readonly imageUrl: string | null;
|
declare readonly imageUrl: string | null;
|
||||||
|
declare readonly isPrivate: boolean | null;
|
||||||
|
declare readonly privateLocked: boolean | null;
|
||||||
|
declare readonly privateHint: string | null;
|
||||||
declare readonly sessionId: string;
|
declare readonly sessionId: string;
|
||||||
|
|
||||||
private constructor(input: LocalMessageInput) {
|
private constructor(input: LocalMessageInput) {
|
||||||
@@ -63,6 +69,9 @@ export class LocalMessage {
|
|||||||
content: this.content,
|
content: this.content,
|
||||||
createdAt: this.createdAt,
|
createdAt: this.createdAt,
|
||||||
imageUrl: this.imageUrl,
|
imageUrl: this.imageUrl,
|
||||||
|
isPrivate: this.isPrivate,
|
||||||
|
privateLocked: this.privateLocked,
|
||||||
|
privateHint: this.privateHint,
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -75,6 +84,9 @@ export class LocalMessage {
|
|||||||
content: row.content,
|
content: row.content,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
imageUrl: row.imageUrl ?? null,
|
imageUrl: row.imageUrl ?? null,
|
||||||
|
isPrivate: row.isPrivate ?? null,
|
||||||
|
privateLocked: row.privateLocked ?? null,
|
||||||
|
privateHint: row.privateHint ?? null,
|
||||||
sessionId: row.sessionId,
|
sessionId: row.sessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface ChatState {
|
|||||||
paywallTriggered: boolean;
|
paywallTriggered: boolean;
|
||||||
paywallReason: MachineContext["paywallReason"];
|
paywallReason: MachineContext["paywallReason"];
|
||||||
paywallDetail: MachineContext["paywallDetail"];
|
paywallDetail: MachineContext["paywallDetail"];
|
||||||
|
unlockingPrivateMessageId: MachineContext["unlockingPrivateMessageId"];
|
||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
historyOffset: number;
|
historyOffset: number;
|
||||||
@@ -49,6 +50,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
|||||||
paywallTriggered: state.context.paywallTriggered,
|
paywallTriggered: state.context.paywallTriggered,
|
||||||
paywallReason: state.context.paywallReason,
|
paywallReason: state.context.paywallReason,
|
||||||
paywallDetail: state.context.paywallDetail,
|
paywallDetail: state.context.paywallDetail,
|
||||||
|
unlockingPrivateMessageId: state.context.unlockingPrivateMessageId,
|
||||||
isLoadingMore: state.context.isLoadingMore,
|
isLoadingMore: state.context.isLoadingMore,
|
||||||
hasMore: state.context.hasMore,
|
hasMore: state.context.hasMore,
|
||||||
historyOffset: state.context.historyOffset,
|
historyOffset: state.context.historyOffset,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type ChatEvent =
|
|||||||
// 业务事件
|
// 业务事件
|
||||||
| { type: "ChatSendMessage"; content: string }
|
| { type: "ChatSendMessage"; content: string }
|
||||||
| { type: "ChatSendImage"; imageBase64: string }
|
| { type: "ChatSendImage"; imageBase64: string }
|
||||||
|
| { type: "ChatUnlockPrivateMessage"; messageId: string }
|
||||||
| { type: "ChatLoadMoreHistory" }
|
| { type: "ChatLoadMoreHistory" }
|
||||||
| { type: "ChatImageReceived"; imageUrl: string }
|
| { type: "ChatImageReceived"; imageUrl: string }
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -105,6 +105,41 @@ export const loadMoreHistoryActor = fromPromise<
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const unlockPrivateMessageActor = fromPromise<
|
||||||
|
{
|
||||||
|
messageId: string;
|
||||||
|
response: import("@/data/dto/chat").UnlockPrivateResponse;
|
||||||
|
},
|
||||||
|
{ messageId: string }
|
||||||
|
>(async ({ input }) => {
|
||||||
|
const result = await chatRepo.unlockPrivateMessage(input.messageId);
|
||||||
|
if (Result.isErr(result)) {
|
||||||
|
log.error("[chat-machine] unlockPrivateMessageActor failed", {
|
||||||
|
messageId: input.messageId,
|
||||||
|
error: result.error,
|
||||||
|
});
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data.unlocked && result.data.content != null) {
|
||||||
|
const localResult = await chatRepo.markPrivateMessageUnlockedInLocal(
|
||||||
|
input.messageId,
|
||||||
|
result.data.content,
|
||||||
|
);
|
||||||
|
if (Result.isErr(localResult)) {
|
||||||
|
log.error("[chat-machine] unlockPrivateMessageActor local sync failed", {
|
||||||
|
messageId: input.messageId,
|
||||||
|
error: localResult.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageId: input.messageId,
|
||||||
|
response: result.data,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// WebSocket: long-lived callback
|
// WebSocket: long-lived callback
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -49,17 +49,25 @@ export const chatRepo: IChatRepository = chatRepository;
|
|||||||
*/
|
*/
|
||||||
export function localMessagesToUi(
|
export function localMessagesToUi(
|
||||||
records: ReadonlyArray<{
|
records: ReadonlyArray<{
|
||||||
|
id?: string;
|
||||||
content: string;
|
content: string;
|
||||||
role: string;
|
role: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
|
isPrivate?: boolean | null;
|
||||||
|
privateLocked?: boolean | null;
|
||||||
|
privateHint?: string | null;
|
||||||
}>,
|
}>,
|
||||||
): UiMessage[] {
|
): UiMessage[] {
|
||||||
return records.map((m) => ({
|
return records.map((m) => ({
|
||||||
|
...(m.id ? { id: m.id } : {}),
|
||||||
content: m.content,
|
content: m.content,
|
||||||
isFromAI: m.role === "assistant",
|
isFromAI: m.role === "assistant",
|
||||||
date: messageDateFromCreatedAt(m.createdAt),
|
date: messageDateFromCreatedAt(m.createdAt),
|
||||||
...(m.imageUrl ? { imageUrl: m.imageUrl } : {}),
|
...(m.imageUrl ? { imageUrl: m.imageUrl } : {}),
|
||||||
|
...(m.isPrivate != null ? { isPrivate: m.isPrivate } : {}),
|
||||||
|
...(m.privateLocked != null ? { privateLocked: m.privateLocked } : {}),
|
||||||
|
...(m.privateHint != null ? { privateHint: m.privateHint } : {}),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +84,7 @@ function messageDateFromCreatedAt(createdAt: string): string {
|
|||||||
*/
|
*/
|
||||||
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
|
||||||
return {
|
return {
|
||||||
|
...(response.messageId ? { id: response.messageId } : {}),
|
||||||
content: response.reply,
|
content: response.reply,
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
date: todayString(new Date(response.timestamp)),
|
date: todayString(new Date(response.timestamp)),
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
sendMessageWsActor,
|
sendMessageWsActor,
|
||||||
loadMoreHistoryActor,
|
loadMoreHistoryActor,
|
||||||
chatWebSocketActor,
|
chatWebSocketActor,
|
||||||
|
unlockPrivateMessageActor,
|
||||||
} from "./chat-machine.actors";
|
} from "./chat-machine.actors";
|
||||||
|
|
||||||
const log = new Logger("StoresChatChatMachine");
|
const log = new Logger("StoresChatChatMachine");
|
||||||
@@ -70,6 +71,7 @@ export const chatMachine = setup({
|
|||||||
sendMessageWs: sendMessageWsActor,
|
sendMessageWs: sendMessageWsActor,
|
||||||
loadMoreHistory: loadMoreHistoryActor,
|
loadMoreHistory: loadMoreHistoryActor,
|
||||||
chatWebSocket: chatWebSocketActor,
|
chatWebSocket: chatWebSocketActor,
|
||||||
|
unlockPrivateMessage: unlockPrivateMessageActor,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
startGuestSession: assign(() => ({
|
startGuestSession: assign(() => ({
|
||||||
@@ -276,6 +278,65 @@ export const chatMachine = setup({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setUnlockingPrivateMessage: assign(({ event }) => {
|
||||||
|
if (event.type !== "ChatUnlockPrivateMessage") return {};
|
||||||
|
return {
|
||||||
|
unlockingPrivateMessageId: event.messageId,
|
||||||
|
paywallTriggered: false,
|
||||||
|
paywallReason: null,
|
||||||
|
paywallDetail: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
applyUnlockPrivateOutput: assign(({ context, event }) => {
|
||||||
|
if (!("output" in event)) return {};
|
||||||
|
const output = event.output as {
|
||||||
|
messageId: string;
|
||||||
|
response: import("@/data/dto/chat").UnlockPrivateResponse;
|
||||||
|
};
|
||||||
|
const { messageId, response } = output;
|
||||||
|
|
||||||
|
if (response.unlocked && response.content != null) {
|
||||||
|
return {
|
||||||
|
messages: context.messages.map((message) =>
|
||||||
|
message.id === messageId
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
content: response.content ?? message.content,
|
||||||
|
privateLocked: false,
|
||||||
|
privateHint: null,
|
||||||
|
isPrivate: message.isPrivate ?? true,
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
unlockingPrivateMessageId: null,
|
||||||
|
paywallTriggered: false,
|
||||||
|
paywallReason: null,
|
||||||
|
paywallDetail: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.showUpgrade) {
|
||||||
|
return {
|
||||||
|
unlockingPrivateMessageId: null,
|
||||||
|
paywallTriggered: true,
|
||||||
|
paywallReason: "private_paywall",
|
||||||
|
paywallDetail: {
|
||||||
|
usedToday: response.privateUsedToday,
|
||||||
|
limit: response.privateFreeLimit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
unlockingPrivateMessageId: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
clearUnlockingPrivateMessage: assign({
|
||||||
|
unlockingPrivateMessageId: null,
|
||||||
|
}),
|
||||||
|
|
||||||
setWsConnected: assign({ wsConnected: true }),
|
setWsConnected: assign({ wsConnected: true }),
|
||||||
clearWsConnected: assign({ wsConnected: false }),
|
clearWsConnected: assign({ wsConnected: false }),
|
||||||
},
|
},
|
||||||
@@ -356,6 +417,10 @@ export const chatMachine = setup({
|
|||||||
actions: "appendGuestUserImage",
|
actions: "appendGuestUserImage",
|
||||||
target: "sending",
|
target: "sending",
|
||||||
},
|
},
|
||||||
|
ChatUnlockPrivateMessage: {
|
||||||
|
actions: "setUnlockingPrivateMessage",
|
||||||
|
target: "unlockingPrivate",
|
||||||
|
},
|
||||||
// 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS
|
// 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -377,6 +442,23 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
unlockingPrivate: {
|
||||||
|
invoke: {
|
||||||
|
src: "unlockPrivateMessage",
|
||||||
|
input: ({ event }) => ({
|
||||||
|
messageId:
|
||||||
|
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
|
||||||
|
}),
|
||||||
|
onDone: {
|
||||||
|
target: "ready",
|
||||||
|
actions: "applyUnlockPrivateOutput",
|
||||||
|
},
|
||||||
|
onError: {
|
||||||
|
target: "ready",
|
||||||
|
actions: "clearUnlockingPrivateMessage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -437,6 +519,10 @@ export const chatMachine = setup({
|
|||||||
ChatLoadMoreHistory: {
|
ChatLoadMoreHistory: {
|
||||||
target: "loadingMore",
|
target: "loadingMore",
|
||||||
},
|
},
|
||||||
|
ChatUnlockPrivateMessage: {
|
||||||
|
actions: "setUnlockingPrivateMessage",
|
||||||
|
target: "unlockingPrivate",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sendingViaHttp: {
|
sendingViaHttp: {
|
||||||
@@ -476,6 +562,23 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
unlockingPrivate: {
|
||||||
|
invoke: {
|
||||||
|
src: "unlockPrivateMessage",
|
||||||
|
input: ({ event }) => ({
|
||||||
|
messageId:
|
||||||
|
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
|
||||||
|
}),
|
||||||
|
onDone: {
|
||||||
|
target: "ready",
|
||||||
|
actions: "applyUnlockPrivateOutput",
|
||||||
|
},
|
||||||
|
onError: {
|
||||||
|
target: "ready",
|
||||||
|
actions: "clearUnlockingPrivateMessage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -577,6 +680,10 @@ export const chatMachine = setup({
|
|||||||
ChatLoadMoreHistory: {
|
ChatLoadMoreHistory: {
|
||||||
target: "loadingMore",
|
target: "loadingMore",
|
||||||
},
|
},
|
||||||
|
ChatUnlockPrivateMessage: {
|
||||||
|
actions: "setUnlockingPrivateMessage",
|
||||||
|
target: "unlockingPrivate",
|
||||||
|
},
|
||||||
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
|
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
|
||||||
// 已上提到 vipUserSession.on,子级不再声明
|
// 已上提到 vipUserSession.on,子级不再声明
|
||||||
},
|
},
|
||||||
@@ -651,6 +758,23 @@ export const chatMachine = setup({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
unlockingPrivate: {
|
||||||
|
invoke: {
|
||||||
|
src: "unlockPrivateMessage",
|
||||||
|
input: ({ event }) => ({
|
||||||
|
messageId:
|
||||||
|
event.type === "ChatUnlockPrivateMessage" ? event.messageId : "",
|
||||||
|
}),
|
||||||
|
onDone: {
|
||||||
|
target: "ready",
|
||||||
|
actions: "applyUnlockPrivateOutput",
|
||||||
|
},
|
||||||
|
onError: {
|
||||||
|
target: "ready",
|
||||||
|
actions: "clearUnlockingPrivateMessage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ export interface ChatState {
|
|||||||
*/
|
*/
|
||||||
wsConnected: boolean;
|
wsConnected: boolean;
|
||||||
paywallTriggered: boolean;
|
paywallTriggered: boolean;
|
||||||
paywallReason: "daily_limit" | "photo_paywall" | null;
|
paywallReason: "daily_limit" | "photo_paywall" | "private_paywall" | null;
|
||||||
paywallDetail: { usedToday: number; limit: number } | null;
|
paywallDetail: { usedToday: number; limit: number } | null;
|
||||||
|
unlockingPrivateMessageId: string | null;
|
||||||
isLoadingMore: boolean;
|
isLoadingMore: boolean;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
historyOffset: number;
|
historyOffset: number;
|
||||||
@@ -38,6 +39,7 @@ export const initialState: ChatState = {
|
|||||||
paywallTriggered: false,
|
paywallTriggered: false,
|
||||||
paywallReason: null,
|
paywallReason: null,
|
||||||
paywallDetail: null,
|
paywallDetail: null,
|
||||||
|
unlockingPrivateMessageId: null,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
historyOffset: 0,
|
historyOffset: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user