feat(chat): add private message unlock flow
This commit is contained in:
@@ -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