diff --git a/docs/backend/FRONTEND_LOCKED_MEDIA_UNLOCK_API.md b/docs/backend/FRONTEND_LOCKED_MEDIA_UNLOCK_API.md new file mode 100644 index 00000000..41058a99 --- /dev/null +++ b/docs/backend/FRONTEND_LOCKED_MEDIA_UNLOCK_API.md @@ -0,0 +1,447 @@ +# 前端锁消息解锁 API 接入文档 + +## 2. 功能目标 + +前端可以先展示一条本地锁消息。用户点击解锁时,即使该消息还没有后端 `messageId`,后端也会创建对应的聊天记录,并根据余额决定是否生成真实内容。 + +核心规则: + +1. 余额不足时不生成真实语音、图片或私密内容,不扣积分,但会保存锁消息并返回真实 `messageId`。 +2. 用户充值后,前端使用同一个 `clientLockId` 再次解锁,后端更新原消息,不新增另一条聊天记录。 +3. 成功扣积分并持久化解锁后,`history` 才返回解锁状态。 +4. 刷新页面不会自动解锁。未扣积分、未消耗免费私密额度时,`history` 仍返回 `locked=true`。 +5. 前端只能使用 `unlocked` 或 `lockDetail.locked` 判断锁状态,不能根据 URL 是否为空判断。 + +## 4. 解锁接口 + +### 4.1 请求地址 + +```http +POST /api/chat/unlock-private +``` + +生产环境完整地址: + +```http +POST https://api.banlv-ai.com/api/chat/unlock-private +``` + +### 4.2 请求参数 + +请求体为 JSON: + +```json +{ + "messageId": "可选,有就传", + "lockType": "voice_message", + "clientLockId": "前端生成的唯一ID,可选但强烈建议" +} +``` + +| 字段 | 类型 | 是否必填 | 示例 | 说明 | +| --- | --- | --- | --- | --- | +| `messageId` | string | 否 | `"9a43..."` | 后端真实消息 ID。已有消息时传;前端临时锁消息首次解锁时可以不传。 | +| `lockType` | string | 临时锁消息必填 | `"voice_message"` | 锁类型。没有有效 `messageId` 时必须传。 | +| `clientLockId` | string | 否,强烈建议 | `"fb_voice_3737_001"` | 前端锁卡片唯一 ID,用于充值后重试、刷新找回和避免重复创建。 | + +`lockType` 支持以下值: + +| 标准值 | 兼容别名 | 积分价格 | +| --- | --- | ---: | +| `voice_message` | `voice`、`audio` | 20 | +| `image_paywall` | `image`、`photo`、`picture` | 40 | +| `private_message` | `private`、`private_topic`、`private_room` | 10 | + +建议前端只使用标准值。 + +### 4.3 `clientLockId` 生成规则 + +前端在创建锁卡片时生成一次,之后不得改变: + +```ts +const clientLockId = crypto.randomUUID(); +``` + +同一条锁消息在以下场景中必须继续使用同一个值: + +- 第一次点击解锁; +- 余额不足后进入充值; +- 充值成功后重新点击解锁; +- 网络超时后重试; +- 前端重新渲染同一条本地锁卡片。 + +首次响应拿到后端 `messageId` 后,前端应同时保存 `messageId` 和 `clientLockId`。 + +### 4.4 最小请求示例 + +解锁已有后端消息: + +```bash +curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"messageId":""}' +``` + +解锁前端临时语音锁消息: + +```bash +curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "lockType":"voice_message", + "clientLockId":"codex_test_voice_001" + }' +``` + +充值后建议把三个字段都传回: + +```json +{ + "messageId": "", + "lockType": "voice_message", + "clientLockId": "codex_test_voice_001" +} +``` + +## 5. 统一响应结构 + +```json +{ + "code": 200, + "message": "success", + "success": true, + "data": {} +} +``` + +业务解锁失败通常也返回 HTTP 200。前端必须读取 `data.unlocked` 和 `data.reason`,不能只检查 HTTP 状态或顶层 `success`。 + +重要字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `unlocked` | boolean | 本次操作后是否已真实解锁。 | +| `reason` | string/null | 本次操作结果原因。 | +| `messageId` / `message_id` | string | 后端真实消息 ID。首次临时锁请求后也会返回。 | +| `clientLockId` | string/null | 后端识别到的前端锁 ID。 | +| `lockType` | string/null | 规范化后的锁类型。 | +| `reply` / `response` | string | 当前消息文字。 | +| `content` | string/null | 允许展示时返回内容;私密内容未解锁时可以为 `null`。 | +| `type` / `messageType` | string | `text` 或 `voice`。 | +| `audioUrl` / `audio_url` | string | 锁定语音为空字符串,成功解锁后是完整 URL。 | +| `image.url` | string/null | 图片完整 URL;是否展示仍由 `lockDetail.locked` 决定。 | +| `creditBalance` | number/null | 操作后的当前积分。 | +| `creditsCharged` | number | 本次实际扣除积分;失败或免费额度解锁为 0。 | +| `requiredCredits` | number | 当前锁类型需要的积分。 | +| `shortfallCredits` | number | 余额不足时还差多少积分。 | +| `lockDetail.locked` | boolean | 前端锁卡片的最终展示开关。 | +| `lockDetail.showContent` | boolean | 是否允许展示文字内容。 | +| `lockDetail.showUpgrade` | boolean | 是否展示充值入口。 | +| `lockDetail.reason` | string/null | 锁定原因。 | + +## 6. 余额不足 + +请求: + +```json +{ + "lockType": "voice_message", + "clientLockId": "fb_voice_37370387172559600_001" +} +``` + +代表性响应: + +```json +{ + "code": 200, + "message": "success", + "success": true, + "data": { + "unlocked": false, + "reason": "insufficient_credits", + "type": "voice", + "messageType": "voice", + "messageId": "", + "message_id": "", + "clientLockId": "fb_voice_37370387172559600_001", + "lockType": "voice_message", + "reply": "I left a voice message for you. Unlock it to listen.", + "content": "I left a voice message for you. Unlock it to listen.", + "audioUrl": "", + "audio_url": "", + "creditBalance": 5, + "creditsCharged": 0, + "requiredCredits": 20, + "shortfallCredits": 15, + "lockDetail": { + "locked": true, + "showContent": true, + "showUpgrade": true, + "reason": "voice_message", + "detail": { + "messageId": "", + "clientLockId": "fb_voice_37370387172559600_001", + "lockType": "voice_message", + "requiredCredits": 20 + } + } + } +} +``` + +前端处理: + +1. 使用返回的 `messageId` 替换本地临时消息 ID。 +2. 保留原 `clientLockId`。 +3. 保持锁定状态并打开充值页面。 +4. 充值完成后再次调用解锁接口。 +5. 请求期间禁用解锁按钮,避免用户连续点击。 + +## 7. 解锁成功 + +### 7.1 语音成功 + +后端先确认并扣除 20 积分,然后生成真实文字和语音,更新同一条聊天记录: + +```json +{ + "code": 200, + "message": "success", + "success": true, + "data": { + "unlocked": true, + "reason": "ok", + "type": "voice", + "messageType": "voice", + "messageId": "", + "clientLockId": "fb_voice_37370387172559600_001", + "lockType": "voice_message", + "reply": "I made this voice just for you.", + "content": "I made this voice just for you.", + "audioUrl": "https://api.banlv-ai.com/audio/.mp3", + "audio_url": "https://api.banlv-ai.com/audio/.mp3", + "creditBalance": 10, + "creditsCharged": 20, + "requiredCredits": 20, + "shortfallCredits": 0, + "lockDetail": { + "locked": false, + "showContent": true, + "showUpgrade": false + } + } +} +``` + +前端收到后直接用本次响应替换锁卡片,播放地址读取 `data.audioUrl`。 + +### 7.2 图片成功 + +```json +{ + "unlocked": true, + "reason": "ok", + "messageId": "", + "clientLockId": "fb_image_001", + "lockType": "image_paywall", + "image": { + "type": "elio_schedule", + "url": "https://dbapi.banlv-ai.com/storage/v1/object/public/" + }, + "creditBalance": 60, + "creditsCharged": 40, + "requiredCredits": 40, + "lockDetail": { + "locked": false, + "showContent": true, + "showUpgrade": false + } +} +``` + +图片读取 `data.image.url`。后端已有图片锁消息可能在锁定时也返回真实 URL,因为 URL 本身不作为解锁凭证;前端仍必须根据 `lockDetail.locked` 遮挡或禁止查看。 + +前端临时创建、尚未确定图片内容的锁消息,在成功扣积分前可能没有图片 URL;成功后后端选择图片并返回 URL。 + +### 7.3 私密文本成功 + +前端临时私密消息按 10 积分生成并解锁: + +```json +{ + "unlocked": true, + "reason": "ok", + "messageId": "", + "clientLockId": "fb_private_001", + "lockType": "private_message", + "reply": "", + "content": "", + "creditsCharged": 10, + "requiredCredits": 10, + "lockDetail": { + "locked": false, + "showContent": true, + "showUpgrade": false + } +} +``` + +已有后端私密文本仍优先使用每日免费私密额度:登录用户默认每天 2 条,游客默认每天 1 条。成功消耗免费额度后也会持久解锁,但 `creditsCharged=0`。 + +## 8. History 接口 + +### 8.1 请求 + +```http +GET /api/chat/history?limit=50&offset=0 +Authorization: Bearer +``` + +```bash +curl 'https://api.banlv-ai.com/api/chat/history?limit=50&offset=0' \ + -H 'Authorization: Bearer ' +``` + +`limit=1&offset=0` 返回最新的一条数据库聊天记录展开后的消息;接口最终按聊天展示顺序返回 `data.messages`。 + +### 8.2 锁定消息示例 + +```json +{ + "role": "assistant", + "type": "voice", + "content": "I left a voice message for you. Unlock it to listen.", + "id": "", + "created_at": "2026-07-13T08:00:00+00:00", + "audioUrl": null, + "image": { + "type": null, + "url": null + }, + "lockDetail": { + "locked": true, + "showContent": true, + "showUpgrade": true, + "reason": "voice_message", + "detail": { + "messageId": "", + "clientLockId": "fb_voice_37370387172559600_001", + "lockType": "voice_message", + "requiredCredits": 20 + } + } +} +``` + +### 8.3 前端展示规则 + +```ts +const locked = message.lockDetail?.locked === true; +const showText = message.lockDetail?.showContent !== false; + +if (locked) { + // 展示锁卡片、遮罩或解锁按钮 +} else { + // 展示真实内容 +} +``` + +各类型规则: + +| 状态 | `lockDetail.locked` | URL/内容行为 | +| --- | ---: | --- | +| 锁定语音 | `true` | `audioUrl=null`,不能播放。 | +| 已解锁语音 | `false` | `audioUrl` 是完整可播放 URL。 | +| 锁定图片 | `true` | `image.url` 可能有值,也可能为空;必须遮挡。 | +| 已解锁图片 | `false` | 使用完整 `image.url` 展示。 | +| 锁定私密文本 | `true` | 根据 `showContent` 决定是否显示 `content`。 | +| 已解锁私密文本 | `false` | 展示完整 `content`。 | + +## 9. 推荐 TypeScript 类型 + +```ts +type LockType = "voice_message" | "image_paywall" | "private_message"; + +interface UnlockPrivateRequest { + messageId?: string; + lockType?: LockType; + clientLockId?: string; +} + +interface LockDetail { + locked: boolean; + showContent: boolean; + showUpgrade: boolean; + reason?: string | null; + hint?: string | null; + actionLabel?: string | null; + detail?: { + messageId?: string; + clientLockId?: string; + lockType?: LockType; + requiredCredits?: number; + refundedCredits?: number; + } | null; +} + +interface UnlockPrivateData { + unlocked: boolean; + reason?: string | null; + messageId: string; + message_id: string; + clientLockId?: string | null; + lockType?: LockType | null; + type: "text" | "voice"; + messageType: "text" | "voice"; + reply: string; + response: string; + content?: string | null; + audioUrl: string; + audio_url: string; + image: { + type?: string | null; + url?: string | null; + }; + creditBalance?: number | null; + creditsCharged: number; + requiredCredits: number; + shortfallCredits: number; + displayMessage: string; + localizedMessage: string; + lockDetail: LockDetail; +} +``` + +## 10. 异常与前端处理 + +| `data.reason` | 含义 | 前端处理 | +| --- | --- | --- | +| `ok` | 成功扣费并解锁 | 用响应替换锁卡片。 | +| `not_locked` | 消息已经不是锁定状态 | 直接按已解锁内容展示,不再扣费。 | +| `insufficient_credits` | 积分不足 | 保存返回的 `messageId`,保持锁定并进入充值。 | +| `content_generation_failed` | 临时锁内容生成失败 | 后端尝试退回本次积分;保持锁定,允许稍后重试。 | +| `voice_generation_failed` | 已有语音消息生成失败 | 未扣积分,保持锁定并允许重试。 | +| `quota_exhausted` | 已有私密文本每日免费额度用完 | 展示 `displayMessage`。 | +| `invalid_lock_type` | `lockType` 不支持 | 记录前端错误并停止请求。 | +| `not_found` | 消息不存在且没有有效 `lockType` | 禁用当前解锁按钮或重新同步 history。 | + +认证失败会返回 HTTP 401。请求字段类型不合法可能返回 HTTP 422。 + +## 11. 前端完整流程 + +```text +创建本地锁卡片并生成 clientLockId + -> 用户点击解锁 + -> POST /api/chat/unlock-private + -> unlocked=true:替换卡片并展示真实内容 + -> insufficient_credits:保存 messageId,打开充值 + -> 充值成功 + -> 使用相同 messageId + lockType + clientLockId 重试 + -> unlocked=true:替换卡片 + -> 页面刷新 + -> GET /api/chat/history + -> 只按 lockDetail.locked 恢复锁定或解锁状态 +``` diff --git a/docs/external-entry.md b/docs/external-entry.md new file mode 100644 index 00000000..6a7d2d11 --- /dev/null +++ b/docs/external-entry.md @@ -0,0 +1,62 @@ +# 外部应用入口 + +外部应用统一通过以下地址进入本应用: + +```text +https:///external-entry +``` + +入口会先保存传入的身份数据,再使用 `replace` 跳转到目标页面并清理地址栏中的参数。 + +## 参数 + +| 参数 | 必填 | 可选值 | 说明 | +| --- | --- | --- | --- | +| `target` | 否 | `chat`、`tip`、`private-room` | 最终页面,缺失或无效时进入 `chat`。 | +| `device_id` | 否 | string | 外部设备 ID。 | +| `asid` | 否 | string | Facebook App-scoped User ID。 | +| `psid` | 否 | string | Facebook Page-scoped User ID。 | +| `avatar_url` | 否 | URL string | 用户头像地址。 | +| `mode` | 否 | `promotion` | 设置为 `promotion` 时尝试开启聊天促销模式。 | +| `promotion_type` | 促销模式必填 | `voice`、`image`、`private` | 指定促销锁消息类型。 | + +参数名称只接受表格中的规范写法,不兼容 camelCase、旧 Facebook 字段名、`redirect`、`next` 或其他目标别名。 + +## 普通入口 + +进入聊天: + +```text +https:///external-entry?target=chat&asid=&psid= +``` + +进入打赏页面: + +```text +https:///external-entry?target=tip +``` + +进入私密空间: + +```text +https:///external-entry?target=private-room +``` + +## 促销入口 + +促销模式只在 `target=chat` 时生效,并且 `mode` 与 `promotion_type` 必须同时有效: + +```text +https:///external-entry?target=chat&mode=promotion&promotion_type=voice&asid=&psid= +``` + +进入聊天后,正常历史仍会加载,列表底部会额外展示一条对应类型的锁定促销消息。消息首次解锁使用前端生成的 `clientLockId`,真实内容、积分价格和后端 `messageId` 均由 `/api/chat/unlock-private` 返回。 + +促销入口只消费一次。普通刷新或主动离开聊天页后不会再次注入;登录或充值回跳会继续恢复当前解锁流程。 + +## 接入要求 + +- 所有参数必须使用 URL 编码,尤其是 `avatar_url`。 +- 不要通过入口传递登录 token、Page Access Token 或 App Secret。 +- `promotion_type` 非法时按普通聊天入口处理,不展示促销消息。 +- 不支持任意 URL 跳转;无效 `target` 固定回退到聊天页。 diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 77b546d4..d89b6fc2 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -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() { /> @@ -201,8 +247,19 @@ export function ChatScreen() { onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog} onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog} /> + { + it("renders the promotion hint and an enabled unlock action", () => { + const html = renderToStaticMarkup( + 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=""'); + }); +}); diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index a19a52de..ccbd51da 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -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(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} /> ), diff --git a/src/app/chat/components/chat-unlock-dialogs.tsx b/src/app/chat/components/chat-unlock-dialogs.tsx index 200dfbf0..0310e8a6 100644 --- a/src/app/chat/components/chat-unlock-dialogs.tsx +++ b/src/app/chat/components/chat-unlock-dialogs.tsx @@ -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 ( <> - chatDispatch({ type: "ChatUnlockHistoryDismissed" })} - onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })} - /> + {includeHistoryUnlock ? ( + chatDispatch({ type: "ChatUnlockHistoryDismissed" })} + onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })} + /> + ) : null} void; +} + +export function LockedImageMessageCard({ + hint, + isUnlocking = false, + onUnlock, +}: LockedImageMessageCardProps) { + return ( +
+
+
+
+

+ {hint && hint.length > 0 + ? hint + : "Elio sent you a locked image."} +

+ +
+
+ ); +} diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index 16fddf34..dd25e26d 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -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} />