feat(chat): add private message unlock flow

This commit is contained in:
2026-06-23 13:21:57 +08:00
parent c9d75b6fe6
commit ebd44c4980
20 changed files with 455 additions and 29 deletions
+26 -14
View File
@@ -8,7 +8,7 @@ import type { LoginStatus } from "@/data/dto/auth";
import { AppStorage } from "@/data/storage/app/app_storage";
import { AuthStorage } from "@/data/storage/auth/auth_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 { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
@@ -35,6 +35,7 @@ function deriveIsGuest(loginStatus: LoginStatus): boolean {
export function ChatScreen() {
const state = useChatState();
const chatDispatch = useChatDispatch();
const authState = useAuthState();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false);
@@ -48,8 +49,16 @@ export function ChatScreen() {
state.paywallReason === "daily_limit";
const showPhotoPaywallBanner =
state.paywallTriggered && state.paywallReason === "photo_paywall";
const showExhaustedBanner =
showDailyLimitBanner || showPhotoPaywallBanner;
const showPrivatePaywallBanner =
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);
@@ -122,6 +131,10 @@ export function ChatScreen() {
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash);
}
function handleUnlockPrivateMessage(messageId: string): void {
chatDispatch({ type: "ChatUnlockPrivateMessage", messageId });
}
return (
<MobileShell>
<div className={styles.shell}>
@@ -144,20 +157,19 @@ export function ChatScreen() {
messages={state.messages}
isReplyingAI={state.isReplyingAI}
isGuest={isGuest}
unlockingPrivateMessageId={state.unlockingPrivateMessageId}
onUnlockPrivateMessage={handleUnlockPrivateMessage}
/>
{showExhaustedBanner ? (
{showInlineUpgradeBanner ? (
<ChatQuotaExhaustedBanner
title={inlineUpgradeTitle}
ctaLabel={inlineUpgradeCta}
/>
) : null}
{showDailyLimitBanner ? (
<ChatQuotaExhaustedBanner
title={
showPhotoPaywallBanner
? "Unlock VIP to view Elio's photos"
: undefined
}
ctaLabel={
showPhotoPaywallBanner
? "Activate VIP to view photos"
: undefined
}
/>
) : (
<ChatInputBar disabled={state.isReplyingAI} />
+26 -3
View File
@@ -28,9 +28,16 @@ export interface ChatAreaProps {
messages: readonly UiMessage[];
isReplyingAI: 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 prevLengthRef = useRef(messages.length);
@@ -49,7 +56,11 @@ export function ChatArea({ messages, isReplyingAI }: ChatAreaProps) {
<main ref={scrollRef} className={styles.area} aria-label="Chat messages">
<AiDisclosureBanner />
{renderMessagesWithDateHeaders(messages)}
{renderMessagesWithDateHeaders(
messages,
unlockingPrivateMessageId,
onUnlockPrivateMessage,
)}
{isReplyingAI && <LottieMessageBubble />}
</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 }> = [];
messages.forEach((m, i) => {
@@ -73,9 +88,17 @@ function renderMessagesWithDateHeaders(messages: readonly UiMessage[]) {
) : (
<MessageBubble
key={item.key}
id={item.message.id}
content={item.message.content}
imageUrl={item.message.imageUrl}
isFromAI={item.message.isFromAI}
privateLocked={item.message.privateLocked}
privateHint={item.message.privateHint}
isUnlockingPrivate={
item.message.id != null &&
item.message.id === unlockingPrivateMessageId
}
onUnlockPrivateMessage={onUnlockPrivateMessage}
/>
),
);
+1
View File
@@ -22,5 +22,6 @@ export * from "./message-bubble";
export * from "./message-content";
export * from "./pwa-install-dialog";
export * from "./pwa-install-overlay";
export * from "./private-message-card";
export * from "./text-bubble";
export * from "./user-message-avatar";
+30 -2
View File
@@ -15,12 +15,26 @@ import { MessageContent } from "./message-content";
import styles from "./chat-area.module.css";
export interface MessageBubbleProps {
id?: string;
content: string;
imageUrl?: string | null;
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();
if (isFromAI) {
@@ -29,9 +43,14 @@ export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProp
<MessageAvatar isFromAI={true} />
<div style={{ width: 8 }} />
<MessageContent
messageId={id}
content={content}
imageUrl={imageUrl}
isFromAI={true}
privateLocked={privateLocked}
privateHint={privateHint}
isUnlockingPrivate={isUnlockingPrivate}
onUnlockPrivateMessage={onUnlockPrivateMessage}
/>
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
</div>
@@ -41,7 +60,16 @@ export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProp
return (
<div className={styles.bubbleRowUser} aria-label="User message">
<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 }} />
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
</div>
+33 -3
View File
@@ -10,19 +10,35 @@
* - 两者都有:图片在上,文字在下
*/
import { ImageBubble } from "./image-bubble";
import { PrivateMessageCard } from "./private-message-card";
import { TextBubble } from "./text-bubble";
export interface MessageContentProps {
messageId?: string;
content: string;
imageUrl?: string | null;
isFromAI: boolean;
privateLocked?: boolean | null;
privateHint?: string | null;
isUnlockingPrivate?: boolean;
onUnlockPrivateMessage?: (messageId: string) => void;
}
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 hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER;
const isLockedPrivateMessage = privateLocked === true;
return (
<div
@@ -37,8 +53,22 @@ export function MessageContent({ content, imageUrl, isFromAI }: MessageContentPr
gap: 8,
}}
>
{hasImage && imageUrl && <ImageBubble imageUrl={imageUrl} />}
{hasText && <TextBubble content={content} isFromAI={isFromAI} />}
{isLockedPrivateMessage ? (
<PrivateMessageCard
hint={privateHint}
isUnlocking={isUnlockingPrivate}
onUnlock={
messageId
? () => onUnlockPrivateMessage?.(messageId)
: undefined
}
/>
) : (
<>
{hasImage && imageUrl && <ImageBubble imageUrl={imageUrl} />}
{hasText && <TextBubble content={content} isFromAI={isFromAI} />}
</>
)}
</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>
);
}