feat(chat): add private message unlock flow
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user