feat(chat): add promotional external entry flow
This commit is contained in:
@@ -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_BASE_URL>/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 <TOKEN>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"messageId":"<MESSAGE_ID>"}'
|
||||
```
|
||||
|
||||
解锁前端临时语音锁消息:
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://api.banlv-ai.com/api/chat/unlock-private' \
|
||||
-H 'Authorization: Bearer <TOKEN>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"lockType":"voice_message",
|
||||
"clientLockId":"codex_test_voice_001"
|
||||
}'
|
||||
```
|
||||
|
||||
充值后建议把三个字段都传回:
|
||||
|
||||
```json
|
||||
{
|
||||
"messageId": "<FIRST_RESPONSE_MESSAGE_ID>",
|
||||
"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": "<BACKEND_MESSAGE_ID>",
|
||||
"message_id": "<BACKEND_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": "<BACKEND_MESSAGE_ID>",
|
||||
"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": "<BACKEND_MESSAGE_ID>",
|
||||
"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/<FILE>.mp3",
|
||||
"audio_url": "https://api.banlv-ai.com/audio/<FILE>.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": "<BACKEND_MESSAGE_ID>",
|
||||
"clientLockId": "fb_image_001",
|
||||
"lockType": "image_paywall",
|
||||
"image": {
|
||||
"type": "elio_schedule",
|
||||
"url": "https://dbapi.banlv-ai.com/storage/v1/object/public/<PATH>"
|
||||
},
|
||||
"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": "<BACKEND_MESSAGE_ID>",
|
||||
"clientLockId": "fb_private_001",
|
||||
"lockType": "private_message",
|
||||
"reply": "<REAL_PRIVATE_REPLY>",
|
||||
"content": "<REAL_PRIVATE_REPLY>",
|
||||
"creditsCharged": 10,
|
||||
"requiredCredits": 10,
|
||||
"lockDetail": {
|
||||
"locked": false,
|
||||
"showContent": true,
|
||||
"showUpgrade": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
已有后端私密文本仍优先使用每日免费私密额度:登录用户默认每天 2 条,游客默认每天 1 条。成功消耗免费额度后也会持久解锁,但 `creditsCharged=0`。
|
||||
|
||||
## 8. History 接口
|
||||
|
||||
### 8.1 请求
|
||||
|
||||
```http
|
||||
GET <API_BASE_URL>/api/chat/history?limit=50&offset=0
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
```bash
|
||||
curl 'https://api.banlv-ai.com/api/chat/history?limit=50&offset=0' \
|
||||
-H 'Authorization: Bearer <TOKEN>'
|
||||
```
|
||||
|
||||
`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": "<BACKEND_MESSAGE_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": "<BACKEND_MESSAGE_ID>",
|
||||
"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 恢复锁定或解锁状态
|
||||
```
|
||||
@@ -0,0 +1,62 @@
|
||||
# 外部应用入口
|
||||
|
||||
外部应用统一通过以下地址进入本应用:
|
||||
|
||||
```text
|
||||
https://<APP_HOST>/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://<APP_HOST>/external-entry?target=chat&asid=<ASID>&psid=<PSID>
|
||||
```
|
||||
|
||||
进入打赏页面:
|
||||
|
||||
```text
|
||||
https://<APP_HOST>/external-entry?target=tip
|
||||
```
|
||||
|
||||
进入私密空间:
|
||||
|
||||
```text
|
||||
https://<APP_HOST>/external-entry?target=private-room
|
||||
```
|
||||
|
||||
## 促销入口
|
||||
|
||||
促销模式只在 `target=chat` 时生效,并且 `mode` 与 `promotion_type` 必须同时有效:
|
||||
|
||||
```text
|
||||
https://<APP_HOST>/external-entry?target=chat&mode=promotion&promotion_type=voice&asid=<ASID>&psid=<PSID>
|
||||
```
|
||||
|
||||
进入聊天后,正常历史仍会加载,列表底部会额外展示一条对应类型的锁定促销消息。消息首次解锁使用前端生成的 `clientLockId`,真实内容、积分价格和后端 `messageId` 均由 `/api/chat/unlock-private` 返回。
|
||||
|
||||
促销入口只消费一次。普通刷新或主动离开聊天页后不会再次注入;登录或充值回跳会继续恢复当前解锁流程。
|
||||
|
||||
## 接入要求
|
||||
|
||||
- 所有参数必须使用 URL 编码,尤其是 `avatar_url`。
|
||||
- 不要通过入口传递登录 token、Page Access Token 或 App Secret。
|
||||
- `promotion_type` 非法时按普通聊天入口处理,不展示促销消息。
|
||||
- 不支持任意 URL 跳转;无效 `target` 固定回退到聊天页。
|
||||
@@ -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() {
|
||||
/>
|
||||
|
||||
<ChatArea
|
||||
messages={state.messages}
|
||||
messages={visibleMessages}
|
||||
isReplyingAI={state.isReplyingAI}
|
||||
isUnlockingMessage={state.isUnlockingMessage}
|
||||
unlockingMessageId={state.unlockingMessageId}
|
||||
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
||||
onUnlockVoiceMessage={handleUnlockVoiceMessage}
|
||||
onUnlockImageMessage={handleUnlockImageMessage}
|
||||
onOpenImage={handleOpenImage}
|
||||
/>
|
||||
|
||||
@@ -201,8 +247,19 @@ export function ChatScreen() {
|
||||
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
|
||||
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
|
||||
/>
|
||||
<ChatUnlockDialogs
|
||||
unlockPaywallRequest={promotionUnlockPaywallRequest}
|
||||
includeHistoryUnlock={false}
|
||||
onCloseInsufficientCreditsDialog={
|
||||
closePromotionInsufficientCreditsDialog
|
||||
}
|
||||
onConfirmInsufficientCreditsDialog={
|
||||
confirmPromotionInsufficientCreditsDialog
|
||||
}
|
||||
/>
|
||||
<ChatUnlockDialogs
|
||||
unlockPaywallRequest={imageUnlockPaywallRequest}
|
||||
includeHistoryUnlock={false}
|
||||
onCloseInsufficientCreditsDialog={closeImageInsufficientCreditsDialog}
|
||||
onConfirmInsufficientCreditsDialog={
|
||||
confirmImageInsufficientCreditsDialog
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LockedImageMessageCard } from "../locked-image-message-card";
|
||||
|
||||
describe("LockedImageMessageCard", () => {
|
||||
it("renders the promotion hint and an enabled unlock action", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<LockedImageMessageCard
|
||||
hint="Unlock this private photo."
|
||||
onUnlock={() => 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=""');
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLDivElement>(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}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -10,18 +10,21 @@ 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 (
|
||||
<>
|
||||
{includeHistoryUnlock ? (
|
||||
<HistoryUnlockDialog
|
||||
open={chatState.unlockHistoryPromptVisible}
|
||||
lockedCount={chatState.lockedHistoryCount}
|
||||
@@ -30,6 +33,7 @@ export function ChatUnlockDialogs({
|
||||
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
|
||||
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
|
||||
/>
|
||||
) : null}
|
||||
<InsufficientCreditsDialog
|
||||
open={unlockPaywallRequest !== null}
|
||||
creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { ImageIcon, LockKeyhole } from "lucide-react";
|
||||
|
||||
export interface LockedImageMessageCardProps {
|
||||
hint?: string | null;
|
||||
isUnlocking?: boolean;
|
||||
onUnlock?: () => void;
|
||||
}
|
||||
|
||||
export function LockedImageMessageCard({
|
||||
hint,
|
||||
isUnlocking = false,
|
||||
onUnlock,
|
||||
}: LockedImageMessageCardProps) {
|
||||
return (
|
||||
<div
|
||||
className="w-[min(72vw,280px)] overflow-hidden rounded-[var(--responsive-card-radius-sm,18px)] rounded-tl-none border border-[rgba(246,87,160,0.2)] bg-white shadow-[0_4px_14px_rgba(246,87,160,0.14)]"
|
||||
role="group"
|
||||
aria-label="Locked private image"
|
||||
>
|
||||
<div className="relative flex h-32 items-center justify-center bg-[linear-gradient(145deg,#ffeaf3,#fff7fb)] text-[#f657a0]">
|
||||
<ImageIcon size={46} strokeWidth={1.5} aria-hidden="true" />
|
||||
<span className="absolute flex size-10 items-center justify-center rounded-full bg-white/90 shadow-sm">
|
||||
<LockKeyhole size={19} aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-[var(--responsive-card-padding,14px)]">
|
||||
<p className="m-0 text-[var(--responsive-body,14px)] leading-[1.4] text-[#3c3b3b]">
|
||||
{hint && hint.length > 0
|
||||
? hint
|
||||
: "Elio sent you a locked image."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-[var(--spacing-md,12px)] w-full cursor-pointer rounded-full border-0 bg-[linear-gradient(90deg,#ff67e0,#ff52a2)] px-[var(--spacing-md,12px)] py-[clamp(9px,1.852vw,10px)] text-[var(--responsive-body,14px)] font-bold text-white disabled:cursor-not-allowed disabled:opacity-65 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
|
||||
disabled={isUnlocking || !onUnlock}
|
||||
onClick={onUnlock}
|
||||
>
|
||||
{isUnlocking ? "Unlocking..." : "Unlock private image"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
||||
@@ -100,6 +103,7 @@ export function MessageBubble({
|
||||
isUnlockingMessage={isUnlockingMessage}
|
||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||
onUnlockImageMessage={onUnlockImageMessage}
|
||||
onOpenImage={onOpenImage}
|
||||
/>
|
||||
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { ImageBubble } from "./image-bubble";
|
||||
import { LockedImageMessageCard } from "./locked-image-message-card";
|
||||
import { PrivateMessageCard } from "./private-message-card";
|
||||
import { TextBubble } from "./text-bubble";
|
||||
import { VoiceBubble } from "./voice-bubble";
|
||||
@@ -19,6 +20,7 @@ export interface MessageContentProps {
|
||||
isUnlockingMessage?: boolean;
|
||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||
onUnlockImageMessage?: (messageId: string) => void;
|
||||
onOpenImage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
@@ -38,6 +40,7 @@ export function MessageContent({
|
||||
isUnlockingMessage,
|
||||
onUnlockPrivateMessage,
|
||||
onUnlockVoiceMessage,
|
||||
onUnlockImageMessage,
|
||||
onOpenImage,
|
||||
}: MessageContentProps) {
|
||||
const hasImage = imageUrl != null && imageUrl.length > 0;
|
||||
@@ -47,6 +50,9 @@ export function MessageContent({
|
||||
lockedPrivate === true ||
|
||||
(locked === true && lockReason === "private_message");
|
||||
const isLockedVoiceMessage = locked === true && lockReason === "voice_message";
|
||||
const isLockedImageMessage =
|
||||
locked === true &&
|
||||
(lockReason === "image_paywall" || lockReason === "image");
|
||||
const shouldRenderVoiceMessage = hasAudio || isLockedVoiceMessage;
|
||||
const handleUnlockPrivateMessage =
|
||||
messageId && onUnlockPrivateMessage
|
||||
@@ -56,6 +62,10 @@ export function MessageContent({
|
||||
isLockedVoiceMessage && messageId && onUnlockVoiceMessage
|
||||
? () => onUnlockVoiceMessage(messageId)
|
||||
: undefined;
|
||||
const handleUnlockImageMessage =
|
||||
isLockedImageMessage && messageId && onUnlockImageMessage
|
||||
? () => onUnlockImageMessage(messageId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -70,6 +80,12 @@ export function MessageContent({
|
||||
isUnlocking={isUnlockingMessage}
|
||||
onUnlock={handleUnlockPrivateMessage}
|
||||
/>
|
||||
) : isLockedImageMessage && !hasImage ? (
|
||||
<LockedImageMessageCard
|
||||
hint={privateMessageHint}
|
||||
isUnlocking={isUnlockingMessage}
|
||||
onUnlock={handleUnlockImageMessage}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{hasImage && imageUrl && (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
consumePendingChatPromotion,
|
||||
peekPendingChatUnlock,
|
||||
} from "@/lib/navigation/chat_unlock_session";
|
||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||
import { Logger } from "@/utils";
|
||||
|
||||
const log = new Logger("UseChatPromotionBootstrap");
|
||||
|
||||
export function useChatPromotionBootstrap(): boolean {
|
||||
const chatDispatch = useChatDispatch();
|
||||
const startedRef = useRef(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [entryPromotion, pendingUnlock] = await Promise.all([
|
||||
consumePendingChatPromotion(),
|
||||
peekPendingChatUnlock(),
|
||||
]);
|
||||
const promotion = entryPromotion ?? pendingUnlock?.promotion ?? null;
|
||||
|
||||
if (promotion) {
|
||||
chatDispatch({
|
||||
type: "ChatPromotionInjected",
|
||||
promotion,
|
||||
messageId: pendingUnlock?.promotion
|
||||
? pendingUnlock.messageId
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
chatDispatch({ type: "ChatPromotionCleared" });
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Failed to restore chat promotion", error);
|
||||
chatDispatch({ type: "ChatPromotionCleared" });
|
||||
} finally {
|
||||
setIsReady(true);
|
||||
}
|
||||
})();
|
||||
}, [chatDispatch]);
|
||||
|
||||
return isReady;
|
||||
}
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
import {
|
||||
consumePendingChatUnlock,
|
||||
peekPendingChatUnlock,
|
||||
type PendingChatUnlock,
|
||||
type PendingChatUnlockKind,
|
||||
type PendingChatPromotion,
|
||||
} from "@/lib/navigation/chat_unlock_session";
|
||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
||||
@@ -15,11 +17,21 @@ import { useUserState } from "@/stores/user/user-context";
|
||||
|
||||
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
|
||||
|
||||
type PromotionScope = "any" | "only" | "exclude";
|
||||
|
||||
export interface MessageUnlockOptions {
|
||||
remoteMessageId?: string;
|
||||
lockType?: ChatLockType;
|
||||
clientLockId?: string;
|
||||
promotion?: PendingChatPromotion;
|
||||
}
|
||||
|
||||
export interface UseChatUnlockNavigationFlowInput {
|
||||
returnUrl: string;
|
||||
expectedKind?: PendingChatUnlockKind;
|
||||
expectedMessageId?: string;
|
||||
ignoredKind?: PendingChatUnlockKind;
|
||||
promotionScope?: PromotionScope;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -28,6 +40,7 @@ export interface UseChatUnlockNavigationFlowOutput {
|
||||
requestMessageUnlock: (
|
||||
messageId: string,
|
||||
kind: PendingChatUnlockKind,
|
||||
options?: MessageUnlockOptions,
|
||||
) => void;
|
||||
closeInsufficientCreditsDialog: () => void;
|
||||
confirmInsufficientCreditsDialog: () => void;
|
||||
@@ -38,6 +51,7 @@ export function useChatUnlockNavigationFlow({
|
||||
expectedKind,
|
||||
expectedMessageId,
|
||||
ignoredKind,
|
||||
promotionScope = "any",
|
||||
enabled = true,
|
||||
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
|
||||
const navigator = useAppNavigator();
|
||||
@@ -49,6 +63,7 @@ export function useChatUnlockNavigationFlow({
|
||||
expectedKind,
|
||||
expectedMessageId,
|
||||
ignoredKind,
|
||||
promotionScope,
|
||||
enabled,
|
||||
});
|
||||
|
||||
@@ -68,6 +83,7 @@ export function useChatUnlockNavigationFlow({
|
||||
pending,
|
||||
expectedKind,
|
||||
expectedMessageId,
|
||||
promotionScope,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
@@ -78,8 +94,11 @@ export function useChatUnlockNavigationFlow({
|
||||
|
||||
chatDispatch({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId: consumed.messageId,
|
||||
messageId: consumed.displayMessageId,
|
||||
remoteMessageId: consumed.messageId,
|
||||
kind: consumed.kind,
|
||||
lockType: consumed.lockType,
|
||||
clientLockId: consumed.clientLockId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -93,24 +112,34 @@ export function useChatUnlockNavigationFlow({
|
||||
enabled,
|
||||
expectedKind,
|
||||
expectedMessageId,
|
||||
ignoredKind,
|
||||
navigator.isAuthenticatedUser,
|
||||
promotionScope,
|
||||
returnUrl,
|
||||
]);
|
||||
|
||||
function requestMessageUnlock(
|
||||
messageId: string,
|
||||
kind: PendingChatUnlockKind,
|
||||
options: MessageUnlockOptions = {},
|
||||
): void {
|
||||
const remoteMessageId =
|
||||
options.remoteMessageId ?? (options.lockType ? undefined : messageId);
|
||||
navigator.startMessageUnlock({
|
||||
messageId,
|
||||
displayMessageId: messageId,
|
||||
messageId: remoteMessageId,
|
||||
kind,
|
||||
lockType: options.lockType,
|
||||
clientLockId: options.clientLockId,
|
||||
promotion: options.promotion,
|
||||
returnUrl,
|
||||
onAuthenticated: () => {
|
||||
chatDispatch({
|
||||
type: "ChatUnlockMessageRequested",
|
||||
messageId,
|
||||
remoteMessageId,
|
||||
kind,
|
||||
lockType: options.lockType,
|
||||
clientLockId: options.clientLockId,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -125,8 +154,12 @@ export function useChatUnlockNavigationFlow({
|
||||
|
||||
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
|
||||
navigator.openSubscriptionForPendingUnlock({
|
||||
displayMessageId: unlockPaywallRequest.displayMessageId,
|
||||
messageId: unlockPaywallRequest.messageId,
|
||||
kind: unlockPaywallRequest.kind,
|
||||
lockType: unlockPaywallRequest.lockType,
|
||||
clientLockId: unlockPaywallRequest.clientLockId,
|
||||
promotion: unlockPaywallRequest.promotion,
|
||||
returnUrl,
|
||||
type: getInsufficientCreditsSubscriptionType(userState.isVip),
|
||||
});
|
||||
@@ -145,6 +178,7 @@ function getScopedUnlockPaywallRequest(input: {
|
||||
expectedKind?: PendingChatUnlockKind;
|
||||
expectedMessageId?: string;
|
||||
ignoredKind?: PendingChatUnlockKind;
|
||||
promotionScope?: PromotionScope;
|
||||
enabled?: boolean;
|
||||
}): ChatUnlockPaywallRequest | null {
|
||||
const {
|
||||
@@ -152,13 +186,21 @@ function getScopedUnlockPaywallRequest(input: {
|
||||
expectedKind,
|
||||
expectedMessageId,
|
||||
ignoredKind,
|
||||
promotionScope = "any",
|
||||
enabled = true,
|
||||
} = input;
|
||||
if (!enabled) return null;
|
||||
if (!request) return null;
|
||||
if (!enabled || !request) return null;
|
||||
if (promotionScope === "only" && !request.promotion) return null;
|
||||
if (promotionScope === "exclude" && request.promotion) return null;
|
||||
if (ignoredKind && request.kind === ignoredKind) return null;
|
||||
if (expectedKind && request.kind !== expectedKind) return null;
|
||||
if (expectedMessageId && request.messageId !== expectedMessageId) return null;
|
||||
if (
|
||||
expectedMessageId &&
|
||||
request.displayMessageId !== expectedMessageId &&
|
||||
request.messageId !== expectedMessageId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -166,10 +208,22 @@ function matchesPendingUnlockScope(input: {
|
||||
pending: PendingChatUnlock;
|
||||
expectedKind?: PendingChatUnlockKind;
|
||||
expectedMessageId?: string;
|
||||
promotionScope?: PromotionScope;
|
||||
}): boolean {
|
||||
const { pending, expectedKind, expectedMessageId } = input;
|
||||
const {
|
||||
pending,
|
||||
expectedKind,
|
||||
expectedMessageId,
|
||||
promotionScope = "any",
|
||||
} = input;
|
||||
if (promotionScope === "only" && !pending.promotion) return false;
|
||||
if (promotionScope === "exclude" && pending.promotion) return false;
|
||||
if (expectedKind && pending.kind !== expectedKind) return false;
|
||||
if (expectedMessageId && pending.messageId !== expectedMessageId) {
|
||||
if (
|
||||
expectedMessageId &&
|
||||
pending.displayMessageId !== expectedMessageId &&
|
||||
pending.messageId !== expectedMessageId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -5,8 +5,14 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
persistExternalEntryPayload,
|
||||
resolveExternalEntryPromotionType,
|
||||
resolveExternalEntryTarget,
|
||||
} from "@/lib/navigation/external_entry";
|
||||
import {
|
||||
clearPendingChatPromotion,
|
||||
savePendingChatPromotion,
|
||||
} from "@/lib/navigation/chat_unlock_session";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { Logger } from "@/utils";
|
||||
|
||||
const log = new Logger("ExternalEntryPersist");
|
||||
@@ -17,8 +23,8 @@ interface ExternalEntryPersistProps {
|
||||
psid: string | null;
|
||||
avatarUrl: string | null;
|
||||
target: string | null;
|
||||
redirect: string | null;
|
||||
next: string | null;
|
||||
mode: string | null;
|
||||
promotionType: string | null;
|
||||
}
|
||||
|
||||
export default function ExternalEntryPersist({
|
||||
@@ -27,26 +33,31 @@ export default function ExternalEntryPersist({
|
||||
psid,
|
||||
avatarUrl,
|
||||
target,
|
||||
redirect,
|
||||
next,
|
||||
mode,
|
||||
promotionType,
|
||||
}: ExternalEntryPersistProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const destination = resolveExternalEntryTarget({
|
||||
target,
|
||||
redirect,
|
||||
next,
|
||||
const destination = resolveExternalEntryTarget({ target });
|
||||
const resolvedPromotionType = resolveExternalEntryPromotionType({
|
||||
mode,
|
||||
promotionType,
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await persistExternalEntryPayload({
|
||||
await Promise.all([
|
||||
persistExternalEntryPayload({
|
||||
deviceId,
|
||||
asid,
|
||||
psid,
|
||||
avatarUrl,
|
||||
});
|
||||
}),
|
||||
destination === ROUTES.chat && resolvedPromotionType
|
||||
? savePendingChatPromotion(resolvedPromotionType)
|
||||
: clearPendingChatPromotion(),
|
||||
]);
|
||||
} catch (error) {
|
||||
log.warn("[ExternalEntryPersist] failed to persist payload", error);
|
||||
} finally {
|
||||
@@ -57,9 +68,9 @@ export default function ExternalEntryPersist({
|
||||
avatarUrl,
|
||||
asid,
|
||||
deviceId,
|
||||
next,
|
||||
mode,
|
||||
promotionType,
|
||||
psid,
|
||||
redirect,
|
||||
router,
|
||||
target,
|
||||
]);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* 外部应用可以通过 query 传入 Facebook 相关信息和最终目标页,例如:
|
||||
* `/external-entry?target=chat&asid=xxx&psid=yyy`
|
||||
* `/external-entry?target=chat&mode=promotion&promotion_type=voice`
|
||||
*
|
||||
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
|
||||
* 再通过 `router.replace()` 清理 URL 并跳转到最终页面。
|
||||
@@ -23,27 +24,20 @@ export default async function ExternalEntryPage({
|
||||
|
||||
return (
|
||||
<ExternalEntryPersist
|
||||
deviceId={pickFirst(params.deviceId, params.device_id)}
|
||||
asid={pickFirst(params.asid)}
|
||||
psid={pickFirst(params.psid)}
|
||||
avatarUrl={pickFirst(params.avatarUrl, params.avatar_url)}
|
||||
target={pickFirst(params.target)}
|
||||
redirect={pickFirst(params.redirect)}
|
||||
next={pickFirst(params.next)}
|
||||
deviceId={pickParam(params.device_id)}
|
||||
asid={pickParam(params.asid)}
|
||||
psid={pickParam(params.psid)}
|
||||
avatarUrl={pickParam(params.avatar_url)}
|
||||
target={pickParam(params.target)}
|
||||
mode={pickParam(params.mode)}
|
||||
promotionType={pickParam(params.promotion_type)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function pickFirst(
|
||||
...values: Array<string | string[] | undefined>
|
||||
): string | null {
|
||||
for (const value of values) {
|
||||
function pickParam(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
const first = value.find((item) => item.trim().length > 0);
|
||||
if (first) return first;
|
||||
continue;
|
||||
return value.find((item) => item.trim().length > 0) ?? null;
|
||||
}
|
||||
if (value && value.trim().length > 0) return value;
|
||||
}
|
||||
return null;
|
||||
return value && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { UnlockPrivateRequest } from "@/data/dto/chat";
|
||||
|
||||
describe("UnlockPrivateRequest", () => {
|
||||
it("serializes an existing backend message unlock", () => {
|
||||
expect(
|
||||
UnlockPrivateRequest.from({ messageId: "message-1" }).toJson(),
|
||||
).toEqual({ messageId: "message-1" });
|
||||
});
|
||||
|
||||
it("serializes a temporary promotion lock without a fake message id", () => {
|
||||
expect(
|
||||
UnlockPrivateRequest.from({
|
||||
lockType: "voice_message",
|
||||
clientLockId: "promotion-1",
|
||||
}).toJson(),
|
||||
).toEqual({
|
||||
lockType: "voice_message",
|
||||
clientLockId: "promotion-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,34 @@ describe("UnlockPrivateResponse", () => {
|
||||
expect(response.lockDetail.locked).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes backend ids, audio aliases, and unlocked images", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
unlocked: true,
|
||||
message_id: "backend-message-1",
|
||||
clientLockId: "promotion-1",
|
||||
lockType: "image_paywall",
|
||||
content: null,
|
||||
reply: "Unlocked reply",
|
||||
audio_url: "https://example.com/audio.mp3",
|
||||
image: {
|
||||
type: "promotion",
|
||||
url: "https://example.com/image.jpg",
|
||||
},
|
||||
lockDetail: {
|
||||
locked: false,
|
||||
showContent: true,
|
||||
showUpgrade: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.messageId).toBe("backend-message-1");
|
||||
expect(response.clientLockId).toBe("promotion-1");
|
||||
expect(response.lockType).toBe("image_paywall");
|
||||
expect(response.content).toBe("Unlocked reply");
|
||||
expect(response.audioUrl).toBe("https://example.com/audio.mp3");
|
||||
expect(response.image.url).toBe("https://example.com/image.jpg");
|
||||
});
|
||||
|
||||
it("parses an insufficient-balance unlock response", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
unlocked: false,
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
} from "@/data/schemas/chat/unlock_private_request";
|
||||
|
||||
export class UnlockPrivateRequest {
|
||||
declare readonly messageId: string;
|
||||
declare readonly messageId?: string;
|
||||
declare readonly lockType?: UnlockPrivateRequestData["lockType"];
|
||||
declare readonly clientLockId?: string;
|
||||
|
||||
private constructor(input: UnlockPrivateRequestInput) {
|
||||
const data = UnlockPrivateRequestSchema.parse(input);
|
||||
|
||||
@@ -5,14 +5,19 @@ import {
|
||||
type UnlockPrivateResponseInput,
|
||||
} from "@/data/schemas/chat/unlock_private_response";
|
||||
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
||||
import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
|
||||
|
||||
/**
|
||||
* 单条历史付费 / 私密消息解锁响应 DTO。
|
||||
*/
|
||||
export class UnlockPrivateResponse {
|
||||
declare readonly unlocked: boolean;
|
||||
declare readonly messageId: string;
|
||||
declare readonly clientLockId: string | null;
|
||||
declare readonly lockType: ChatLockType | null;
|
||||
declare readonly content: string;
|
||||
declare readonly audioUrl: string;
|
||||
declare readonly image: ChatImageData;
|
||||
declare readonly reason: UnlockPrivateReason;
|
||||
declare readonly creditBalance: number;
|
||||
declare readonly creditsCharged: number;
|
||||
|
||||
@@ -26,6 +26,7 @@ export class ChatLocalMessageStore {
|
||||
...message.toJson(),
|
||||
content: normalizeUnlockedContent(patch.content, message.content),
|
||||
audioUrl: normalizeUnlockedAudioUrl(patch.audioUrl, message.audioUrl),
|
||||
image: patch.image ?? message.image,
|
||||
lockDetail: patch.lockDetail ?? {
|
||||
...message.lockDetail,
|
||||
locked: false,
|
||||
@@ -132,6 +133,8 @@ function shouldMarkMessageUnlocked(
|
||||
if (message.lockDetail.locked !== true) return false;
|
||||
return (
|
||||
Boolean(message.image.url) ||
|
||||
message.lockDetail.reason === "image_paywall" ||
|
||||
message.lockDetail.reason === "image" ||
|
||||
message.lockDetail.reason === "private_message" ||
|
||||
message.lockDetail.reason === "voice_message"
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UnlockPrivateResponse,
|
||||
} from "@/data/dto/chat";
|
||||
import type { ChatApi } from "@/data/services/api";
|
||||
import type { UnlockPrivateMessageInput } from "@/data/repositories/interfaces";
|
||||
import { Result } from "@/utils";
|
||||
|
||||
export class ChatRemoteDataSource {
|
||||
@@ -36,10 +37,10 @@ export class ChatRemoteDataSource {
|
||||
}
|
||||
|
||||
async unlockPrivateMessage(
|
||||
messageId: string,
|
||||
input: UnlockPrivateMessageInput,
|
||||
): Promise<Result<UnlockPrivateResponse>> {
|
||||
return Result.wrap(() =>
|
||||
this.api.unlockPrivateMessage(UnlockPrivateRequest.from({ messageId })),
|
||||
this.api.unlockPrivateMessage(UnlockPrivateRequest.from(input)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
CacheRemoteChatMediaInput,
|
||||
ChatMediaLookupInput,
|
||||
IChatRepository,
|
||||
UnlockPrivateMessageInput,
|
||||
UnlockedPrivateMessageLocalPatch,
|
||||
} from "@/data/repositories/interfaces";
|
||||
import type { Result } from "@/utils";
|
||||
@@ -53,9 +54,9 @@ export class ChatRepository implements IChatRepository {
|
||||
|
||||
/** 解锁单条历史付费 / 私密消息。 */
|
||||
async unlockPrivateMessage(
|
||||
messageId: string,
|
||||
input: UnlockPrivateMessageInput,
|
||||
): Promise<Result<UnlockPrivateResponse>> {
|
||||
return this.remote.unlockPrivateMessage(messageId);
|
||||
return this.remote.unlockPrivateMessage(input);
|
||||
}
|
||||
|
||||
/** 一键解锁历史锁定消息。 */
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
UnlockPrivateResponse,
|
||||
} from "@/data/dto/chat";
|
||||
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
||||
import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
|
||||
import type { LocalChatMediaRow } from "@/data/storage/chat";
|
||||
|
||||
export interface ChatMediaLookupInput {
|
||||
@@ -35,9 +36,16 @@ export interface CacheRemoteChatMediaInput extends ChatMediaLookupInput {
|
||||
export interface UnlockedPrivateMessageLocalPatch {
|
||||
lockDetail?: ChatLockDetailData;
|
||||
audioUrl?: string | null;
|
||||
image?: ChatImageData;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface UnlockPrivateMessageInput {
|
||||
messageId?: string;
|
||||
lockType?: ChatLockType;
|
||||
clientLockId?: string;
|
||||
}
|
||||
|
||||
export interface IChatRepository {
|
||||
/** 发送一条消息。 */
|
||||
sendMessage(
|
||||
@@ -49,7 +57,9 @@ export interface IChatRepository {
|
||||
getHistory(limit?: number, offset?: number): Promise<Result<ChatHistoryResponse>>;
|
||||
|
||||
/** 解锁单条历史付费 / 私密消息。 */
|
||||
unlockPrivateMessage(messageId: string): Promise<Result<UnlockPrivateResponse>>;
|
||||
unlockPrivateMessage(
|
||||
input: UnlockPrivateMessageInput,
|
||||
): Promise<Result<UnlockPrivateResponse>>;
|
||||
|
||||
/** 一键解锁历史锁定消息。 */
|
||||
unlockHistory(): Promise<Result<UnlockHistoryResponse>>;
|
||||
|
||||
@@ -3,9 +3,23 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
export const UnlockPrivateRequestSchema = z.object({
|
||||
messageId: z.string(),
|
||||
});
|
||||
export const ChatLockTypeSchema = z.enum([
|
||||
"voice_message",
|
||||
"image_paywall",
|
||||
"private_message",
|
||||
]);
|
||||
|
||||
export const UnlockPrivateRequestSchema = z
|
||||
.object({
|
||||
messageId: z.string().min(1).optional(),
|
||||
lockType: ChatLockTypeSchema.optional(),
|
||||
clientLockId: z.string().min(1).optional(),
|
||||
})
|
||||
.refine((value) => value.messageId || value.lockType, {
|
||||
message: "messageId or lockType is required",
|
||||
});
|
||||
|
||||
export type ChatLockType = z.output<typeof ChatLockTypeSchema>;
|
||||
|
||||
export type UnlockPrivateRequestInput = z.input<
|
||||
typeof UnlockPrivateRequestSchema
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { booleanOrFalse, numberOrZero, stringOrEmpty } from "../nullable-defaults";
|
||||
import { ChatLockDetailSchema } from "./chat_payloads";
|
||||
import {
|
||||
booleanOrFalse,
|
||||
numberOrZero,
|
||||
stringOrEmpty,
|
||||
stringOrNull,
|
||||
} from "../nullable-defaults";
|
||||
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
|
||||
import { ChatLockTypeSchema } from "./unlock_private_request";
|
||||
|
||||
/**
|
||||
* 单条历史付费 / 私密消息解锁响应。
|
||||
*/
|
||||
export const UnlockPrivateReasonSchema = stringOrEmpty;
|
||||
|
||||
export const UnlockPrivateResponseSchema = z.object({
|
||||
export const UnlockPrivateResponseSchema = z
|
||||
.object({
|
||||
unlocked: booleanOrFalse,
|
||||
content: stringOrEmpty,
|
||||
reply: stringOrEmpty,
|
||||
messageId: stringOrEmpty,
|
||||
message_id: stringOrEmpty,
|
||||
clientLockId: stringOrNull,
|
||||
lockType: ChatLockTypeSchema.nullable().default(null),
|
||||
audioUrl: stringOrEmpty,
|
||||
audio_url: stringOrEmpty,
|
||||
image: ChatImageSchema,
|
||||
reason: UnlockPrivateReasonSchema,
|
||||
creditBalance: numberOrZero,
|
||||
creditsCharged: numberOrZero,
|
||||
requiredCredits: numberOrZero,
|
||||
shortfallCredits: numberOrZero,
|
||||
lockDetail: ChatLockDetailSchema,
|
||||
});
|
||||
})
|
||||
.transform(({ reply, message_id, audio_url, ...data }) => ({
|
||||
...data,
|
||||
content: data.content || reply,
|
||||
messageId: data.messageId || message_id,
|
||||
audioUrl: data.audioUrl || audio_url,
|
||||
}));
|
||||
|
||||
export type UnlockPrivateReason = z.output<typeof UnlockPrivateReasonSchema>;
|
||||
export type UnlockPrivateResponseInput = z.input<
|
||||
|
||||
@@ -52,6 +52,37 @@ describe("NavigationStorage", () => {
|
||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("stores a one-time promotion and carries it through unlock navigation", async () => {
|
||||
const promotion = await NavigationStorage.savePendingChatPromotion("image");
|
||||
|
||||
expect(promotion).toMatchObject({
|
||||
promotionType: "image",
|
||||
lockType: "image_paywall",
|
||||
});
|
||||
expect(promotion.clientLockId).toMatch(/^promotion_/);
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatPromotion(),
|
||||
).resolves.toEqual(promotion);
|
||||
await expect(
|
||||
NavigationStorage.consumePendingChatPromotion(),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
displayMessageId: `promotion:${promotion.clientLockId}`,
|
||||
kind: "image",
|
||||
lockType: promotion.lockType,
|
||||
clientLockId: promotion.clientLockId,
|
||||
promotion,
|
||||
returnUrl: "/chat",
|
||||
stage: "auth",
|
||||
});
|
||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({
|
||||
lockType: "image_paywall",
|
||||
clientLockId: promotion.clientLockId,
|
||||
promotion,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves and consumes pending chat image return sessions", async () => {
|
||||
await NavigationStorage.savePendingChatImageReturn({
|
||||
messageId: "msg_1",
|
||||
|
||||
@@ -3,17 +3,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||
import { ChatLockTypeSchema } from "@/data/schemas/chat";
|
||||
import { Result, SessionAsyncUtil } from "@/utils";
|
||||
|
||||
const MAX_AGE_MS = 30 * 60 * 1000;
|
||||
|
||||
export type PendingChatUnlockKind = "private" | "voice" | "image";
|
||||
export type PendingChatUnlockStage = "auth" | "payment";
|
||||
export type PendingChatPromotionType = "voice" | "image" | "private";
|
||||
|
||||
const PendingChatUnlockSchema = z.object({
|
||||
export const PendingChatPromotionSchema = z.object({
|
||||
promotionType: z.enum(["voice", "image", "private"]),
|
||||
lockType: ChatLockTypeSchema,
|
||||
clientLockId: z.string().min(1),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
const PendingChatUnlockSchema = z
|
||||
.object({
|
||||
reason: z.literal("single_message_unlock"),
|
||||
messageId: z.string().min(1),
|
||||
displayMessageId: z.string().min(1),
|
||||
messageId: z.string().min(1).optional(),
|
||||
kind: z.enum(["private", "voice", "image"]),
|
||||
lockType: ChatLockTypeSchema.optional(),
|
||||
clientLockId: z.string().min(1).optional(),
|
||||
promotion: PendingChatPromotionSchema.optional(),
|
||||
returnUrl: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -23,7 +37,10 @@ const PendingChatUnlockSchema = z.object({
|
||||
),
|
||||
stage: z.enum(["auth", "payment"]),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
})
|
||||
.refine((value) => value.messageId || value.lockType, {
|
||||
message: "messageId or lockType is required",
|
||||
});
|
||||
|
||||
const PendingChatImageReturnSchema = z.object({
|
||||
reason: z.literal("image_paywall"),
|
||||
@@ -39,6 +56,9 @@ const PendingChatImageReturnSchema = z.object({
|
||||
});
|
||||
|
||||
export type PendingChatUnlock = z.output<typeof PendingChatUnlockSchema>;
|
||||
export type PendingChatPromotion = z.output<
|
||||
typeof PendingChatPromotionSchema
|
||||
>;
|
||||
export type PendingChatImageReturn = z.output<
|
||||
typeof PendingChatImageReturnSchema
|
||||
>;
|
||||
@@ -54,15 +74,30 @@ export class NavigationStorage {
|
||||
private constructor() {}
|
||||
|
||||
static async savePendingChatUnlock(input: {
|
||||
messageId: string;
|
||||
displayMessageId?: string;
|
||||
messageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: PendingChatUnlock["lockType"];
|
||||
clientLockId?: string;
|
||||
promotion?: PendingChatPromotion;
|
||||
returnUrl: string;
|
||||
stage: PendingChatUnlockStage;
|
||||
}): Promise<void> {
|
||||
const displayMessageId =
|
||||
input.displayMessageId ??
|
||||
input.messageId ??
|
||||
(input.clientLockId ? `promotion:${input.clientLockId}` : null);
|
||||
if (!displayMessageId) {
|
||||
throw new Error("displayMessageId is required");
|
||||
}
|
||||
const payload: PendingChatUnlock = {
|
||||
reason: "single_message_unlock",
|
||||
messageId: input.messageId,
|
||||
displayMessageId,
|
||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||
kind: input.kind,
|
||||
...(input.lockType ? { lockType: input.lockType } : {}),
|
||||
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
||||
...(input.promotion ? { promotion: input.promotion } : {}),
|
||||
returnUrl: input.returnUrl,
|
||||
stage: input.stage,
|
||||
createdAt: Date.now(),
|
||||
@@ -105,6 +140,37 @@ export class NavigationStorage {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
||||
}
|
||||
|
||||
static async savePendingChatPromotion(
|
||||
promotionType: PendingChatPromotionType,
|
||||
): Promise<PendingChatPromotion> {
|
||||
const promotion: PendingChatPromotion = {
|
||||
promotionType,
|
||||
lockType: toPromotionLockType(promotionType),
|
||||
clientLockId: `promotion_${createUuidV4()}`,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await SessionAsyncUtil.setJson(
|
||||
StorageKeys.pendingChatPromotion,
|
||||
promotion,
|
||||
PendingChatPromotionSchema,
|
||||
);
|
||||
return promotion;
|
||||
}
|
||||
|
||||
static async consumePendingChatPromotion(): Promise<PendingChatPromotion | null> {
|
||||
const result = await SessionAsyncUtil.getJson(
|
||||
StorageKeys.pendingChatPromotion,
|
||||
PendingChatPromotionSchema,
|
||||
);
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||
if (Result.isErr(result)) return null;
|
||||
return NavigationStorage.parsePendingChatPromotion(result.data);
|
||||
}
|
||||
|
||||
static async clearPendingChatPromotion(): Promise<void> {
|
||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion);
|
||||
}
|
||||
|
||||
static async savePendingChatImageReturn(input: {
|
||||
messageId: string;
|
||||
returnUrl: string;
|
||||
@@ -147,4 +213,46 @@ export class NavigationStorage {
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static parsePendingChatPromotion(
|
||||
value: PendingChatPromotion | null,
|
||||
): PendingChatPromotion | null {
|
||||
if (!value) return null;
|
||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function toPromotionLockType(
|
||||
promotionType: PendingChatPromotionType,
|
||||
): PendingChatPromotion["lockType"] {
|
||||
if (promotionType === "voice") return "voice_message";
|
||||
if (promotionType === "image") return "image_paywall";
|
||||
return "private_message";
|
||||
}
|
||||
|
||||
function createUuidV4(): string {
|
||||
const cryptoApi = globalThis.crypto;
|
||||
if (typeof cryptoApi?.randomUUID === "function") {
|
||||
return cryptoApi.randomUUID();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
if (typeof cryptoApi?.getRandomValues === "function") {
|
||||
cryptoApi.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
||||
return [
|
||||
hex.slice(0, 4).join(""),
|
||||
hex.slice(4, 6).join(""),
|
||||
hex.slice(6, 8).join(""),
|
||||
hex.slice(8, 10).join(""),
|
||||
hex.slice(10, 16).join(""),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export const StorageKeys = {
|
||||
chatHistory: "chat_history",
|
||||
pendingChatImageReturn: "pending_chat_image_return",
|
||||
pendingChatUnlock: "pending_chat_unlock",
|
||||
pendingChatPromotion: "pending_chat_promotion",
|
||||
|
||||
// pwa / app info
|
||||
pwaDialogShown: "pwa_dialog_shown",
|
||||
|
||||
@@ -25,10 +25,10 @@ export async function openChatInExternalBrowser(): Promise<void> {
|
||||
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.externalEntry, {
|
||||
queryParams: {
|
||||
target: "chat",
|
||||
deviceId,
|
||||
device_id: deviceId,
|
||||
asid,
|
||||
...(psid ? { psid } : {}),
|
||||
...(avatarUrl ? { avatarUrl } : {}),
|
||||
...(avatarUrl ? { avatar_url: avatarUrl } : {}),
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -2,46 +2,60 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
import { resolveExternalEntryTarget } from "../external_entry";
|
||||
import {
|
||||
resolveExternalEntryPromotionType,
|
||||
resolveExternalEntryTarget,
|
||||
} from "../external_entry";
|
||||
|
||||
describe("external entry navigation", () => {
|
||||
it("defaults to chat", () => {
|
||||
expect(resolveExternalEntryTarget({})).toBe(ROUTES.chat);
|
||||
});
|
||||
|
||||
it("resolves supported target aliases", () => {
|
||||
it("resolves only canonical targets", () => {
|
||||
expect(resolveExternalEntryTarget({ target: "chat" })).toBe(ROUTES.chat);
|
||||
expect(resolveExternalEntryTarget({ target: "tip" })).toBe(ROUTES.tip);
|
||||
expect(resolveExternalEntryTarget({ target: "coffee" })).toBe(ROUTES.tip);
|
||||
expect(resolveExternalEntryTarget({ target: "private-room" })).toBe(
|
||||
ROUTES.privateRoom,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects removed aliases and unsupported targets", () => {
|
||||
expect(resolveExternalEntryTarget({ target: "tips" })).toBe(ROUTES.chat);
|
||||
expect(resolveExternalEntryTarget({ target: "coffee" })).toBe(ROUTES.chat);
|
||||
expect(resolveExternalEntryTarget({ target: "private_room" })).toBe(
|
||||
ROUTES.privateRoom,
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers safe explicit redirect routes over target aliases", () => {
|
||||
expect(
|
||||
resolveExternalEntryTarget({
|
||||
target: "chat",
|
||||
redirect: ROUTES.tip,
|
||||
}),
|
||||
).toBe(ROUTES.tip);
|
||||
});
|
||||
|
||||
it("rejects unsupported and external redirect routes", () => {
|
||||
expect(
|
||||
resolveExternalEntryTarget({
|
||||
redirect: "https://example.com/chat",
|
||||
target: "unknown",
|
||||
}),
|
||||
).toBe(ROUTES.chat);
|
||||
expect(resolveExternalEntryTarget({ redirect: "//example.com" })).toBe(
|
||||
ROUTES.chat,
|
||||
);
|
||||
expect(resolveExternalEntryTarget({ redirect: "/sidebar" })).toBe(
|
||||
ROUTES.chat,
|
||||
);
|
||||
expect(resolveExternalEntryTarget({ target: "/tip" })).toBe(ROUTES.chat);
|
||||
expect(resolveExternalEntryTarget({ target: "sidebar" })).toBe(ROUTES.chat);
|
||||
});
|
||||
});
|
||||
|
||||
describe("external entry promotion", () => {
|
||||
it("accepts only explicit promotion mode and supported message types", () => {
|
||||
expect(
|
||||
resolveExternalEntryPromotionType({
|
||||
mode: "promotion",
|
||||
promotionType: "voice",
|
||||
}),
|
||||
).toBe("voice");
|
||||
expect(
|
||||
resolveExternalEntryPromotionType({
|
||||
mode: "promotion",
|
||||
promotionType: "image",
|
||||
}),
|
||||
).toBe("image");
|
||||
expect(
|
||||
resolveExternalEntryPromotionType({
|
||||
mode: "normal",
|
||||
promotionType: "private",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveExternalEntryPromotionType({
|
||||
mode: "promotion",
|
||||
promotionType: "video",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ describe("subscription exit helpers", () => {
|
||||
it("peeks chat unlock return urls without clearing them", async () => {
|
||||
peekPendingChatUnlockMock.mockResolvedValue({
|
||||
reason: "single_message_unlock",
|
||||
displayMessageId: "msg_1",
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
@@ -85,6 +86,7 @@ describe("subscription exit helpers", () => {
|
||||
consumePendingChatImageReturnMock.mockResolvedValue(null);
|
||||
peekPendingChatUnlockMock.mockResolvedValue({
|
||||
reason: "single_message_unlock",
|
||||
displayMessageId: "msg_1",
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
|
||||
@@ -4,18 +4,26 @@ import {
|
||||
NavigationStorage,
|
||||
type PendingChatUnlock,
|
||||
type PendingChatUnlockKind,
|
||||
type PendingChatPromotion,
|
||||
type PendingChatPromotionType,
|
||||
type PendingChatUnlockStage,
|
||||
} from "@/data/storage/navigation";
|
||||
|
||||
export type {
|
||||
PendingChatUnlock,
|
||||
PendingChatUnlockKind,
|
||||
PendingChatPromotion,
|
||||
PendingChatPromotionType,
|
||||
PendingChatUnlockStage,
|
||||
};
|
||||
|
||||
export async function savePendingChatUnlock(input: {
|
||||
messageId: string;
|
||||
displayMessageId?: string;
|
||||
messageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: PendingChatUnlock["lockType"];
|
||||
clientLockId?: string;
|
||||
promotion?: PendingChatPromotion;
|
||||
returnUrl: string;
|
||||
stage: PendingChatUnlockStage;
|
||||
}): Promise<void> {
|
||||
@@ -37,3 +45,17 @@ export async function hasPendingChatUnlock(): Promise<boolean> {
|
||||
export async function clearPendingChatUnlock(): Promise<void> {
|
||||
await NavigationStorage.clearPendingChatUnlock();
|
||||
}
|
||||
|
||||
export async function savePendingChatPromotion(
|
||||
promotionType: PendingChatPromotionType,
|
||||
): Promise<PendingChatPromotion> {
|
||||
return NavigationStorage.savePendingChatPromotion(promotionType);
|
||||
}
|
||||
|
||||
export async function consumePendingChatPromotion(): Promise<PendingChatPromotion | null> {
|
||||
return NavigationStorage.consumePendingChatPromotion();
|
||||
}
|
||||
|
||||
export async function clearPendingChatPromotion(): Promise<void> {
|
||||
await NavigationStorage.clearPendingChatPromotion();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||
import type { PendingChatPromotionType } from "@/data/storage/navigation";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
export type ExternalEntryTarget =
|
||||
@@ -18,21 +19,34 @@ export interface ExternalEntryPayload {
|
||||
|
||||
export interface ExternalEntryTargetInput {
|
||||
target?: string | null;
|
||||
redirect?: string | null;
|
||||
next?: string | null;
|
||||
}
|
||||
|
||||
export interface ExternalEntryPromotionInput {
|
||||
mode?: string | null;
|
||||
promotionType?: string | null;
|
||||
}
|
||||
|
||||
export function resolveExternalEntryPromotionType({
|
||||
mode,
|
||||
promotionType,
|
||||
}: ExternalEntryPromotionInput): PendingChatPromotionType | null {
|
||||
if (mode?.trim().toLowerCase() !== "promotion") return null;
|
||||
|
||||
const normalizedType = promotionType?.trim().toLowerCase();
|
||||
if (
|
||||
normalizedType === "voice" ||
|
||||
normalizedType === "image" ||
|
||||
normalizedType === "private"
|
||||
) {
|
||||
return normalizedType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveExternalEntryTarget({
|
||||
target,
|
||||
redirect,
|
||||
next,
|
||||
}: ExternalEntryTargetInput): ExternalEntryTarget {
|
||||
return (
|
||||
resolveExplicitRoute(redirect) ??
|
||||
resolveExplicitRoute(next) ??
|
||||
resolveTargetAlias(target) ??
|
||||
ROUTES.chat
|
||||
);
|
||||
return resolveTarget(target) ?? ROUTES.chat;
|
||||
}
|
||||
|
||||
export async function persistExternalEntryPayload({
|
||||
@@ -61,44 +75,19 @@ export async function persistExternalEntryPayload({
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
function resolveExplicitRoute(
|
||||
value: string | null | undefined,
|
||||
): ExternalEntryTarget | null {
|
||||
if (!value) return null;
|
||||
const route = normalizeRoute(value);
|
||||
if (
|
||||
route === ROUTES.chat ||
|
||||
route === ROUTES.tip ||
|
||||
route === ROUTES.privateRoom
|
||||
) {
|
||||
return route;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveTargetAlias(
|
||||
function resolveTarget(
|
||||
value: string | null | undefined,
|
||||
): ExternalEntryTarget | null {
|
||||
if (!value) return null;
|
||||
const target = value.trim().toLowerCase();
|
||||
|
||||
if (target === "chat") return ROUTES.chat;
|
||||
if (target === "tip" || target === "tips") {
|
||||
return ROUTES.tip;
|
||||
}
|
||||
if (target === "private-room") {
|
||||
return ROUTES.privateRoom;
|
||||
}
|
||||
if (target === "tip") return ROUTES.tip;
|
||||
if (target === "private-room") return ROUTES.privateRoom;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeRoute(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("/") || trimmed.startsWith("//")) return "";
|
||||
return trimmed.split(/[?#]/, 1)[0] ?? "";
|
||||
}
|
||||
|
||||
function hasValue(value: string | null | undefined): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PayChannel } from "@/data/dto/payment";
|
||||
import type {
|
||||
PendingChatUnlockKind,
|
||||
PendingChatPromotion,
|
||||
PendingChatUnlockStage,
|
||||
} from "@/data/storage/navigation";
|
||||
|
||||
@@ -15,16 +16,24 @@ export interface OpenSubscriptionInput {
|
||||
}
|
||||
|
||||
export interface StartMessageUnlockInput {
|
||||
messageId: string;
|
||||
displayMessageId: string;
|
||||
messageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: PendingChatPromotion["lockType"];
|
||||
clientLockId?: string;
|
||||
promotion?: PendingChatPromotion;
|
||||
returnUrl: string;
|
||||
stage?: PendingChatUnlockStage;
|
||||
onAuthenticated: () => void;
|
||||
}
|
||||
|
||||
export interface OpenSubscriptionForPendingUnlockInput {
|
||||
messageId: string;
|
||||
displayMessageId: string;
|
||||
messageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: PendingChatPromotion["lockType"];
|
||||
clientLockId?: string;
|
||||
promotion?: PendingChatPromotion;
|
||||
returnUrl: string;
|
||||
type: AppSubscriptionType;
|
||||
payChannel?: PayChannel;
|
||||
|
||||
@@ -108,8 +108,12 @@ export function useAppNavigator(): AppNavigator {
|
||||
|
||||
const startMessageUnlock = useCallback(
|
||||
({
|
||||
displayMessageId,
|
||||
messageId,
|
||||
kind,
|
||||
lockType,
|
||||
clientLockId,
|
||||
promotion,
|
||||
returnUrl,
|
||||
stage = "auth",
|
||||
onAuthenticated,
|
||||
@@ -121,8 +125,12 @@ export function useAppNavigator(): AppNavigator {
|
||||
|
||||
void (async () => {
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
displayMessageId,
|
||||
messageId,
|
||||
kind,
|
||||
lockType,
|
||||
clientLockId,
|
||||
promotion,
|
||||
returnUrl,
|
||||
stage,
|
||||
});
|
||||
@@ -134,16 +142,24 @@ export function useAppNavigator(): AppNavigator {
|
||||
|
||||
const openSubscriptionForPendingUnlock = useCallback(
|
||||
({
|
||||
displayMessageId,
|
||||
messageId,
|
||||
kind,
|
||||
lockType,
|
||||
clientLockId,
|
||||
promotion,
|
||||
returnUrl,
|
||||
type,
|
||||
payChannel = getDefaultPayChannel(),
|
||||
}: OpenSubscriptionForPendingUnlockInput): void => {
|
||||
void (async () => {
|
||||
await NavigationStorage.savePendingChatUnlock({
|
||||
displayMessageId,
|
||||
messageId,
|
||||
kind,
|
||||
lockType,
|
||||
clientLockId,
|
||||
promotion,
|
||||
returnUrl,
|
||||
stage: "payment",
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ function makeResponse(
|
||||
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
return {
|
||||
messages: [],
|
||||
promotion: null,
|
||||
isReplyingAI: true,
|
||||
pendingReplyCount: 1,
|
||||
upgradePromptVisible: false,
|
||||
@@ -56,6 +57,9 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
...overrides,
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
} from "@/data/dto/chat";
|
||||
import { chatMachine } from "@/stores/chat/chat-machine";
|
||||
import type { ChatEvent } from "@/stores/chat/chat-events";
|
||||
import type {
|
||||
UnlockMessageOutput as MachineUnlockMessageOutput,
|
||||
UnlockMessageRequest,
|
||||
} from "@/stores/chat/chat-machine.helpers";
|
||||
|
||||
interface LoadMoreHistoryOutput {
|
||||
messages: UiMessage[];
|
||||
@@ -29,7 +33,7 @@ interface UnlockHistoryOutput {
|
||||
newOffset: number;
|
||||
}
|
||||
|
||||
interface UnlockMessageOutput {
|
||||
interface TestUnlockMessageOutput {
|
||||
messageId: string;
|
||||
response: UnlockPrivateResponse;
|
||||
}
|
||||
@@ -81,7 +85,7 @@ function createTestChatMachine(
|
||||
options: {
|
||||
historyMessages?: UiMessage[];
|
||||
unlockHistoryOutput?: UnlockHistoryOutput;
|
||||
unlockMessageOutput?: UnlockMessageOutput;
|
||||
unlockMessageOutput?: TestUnlockMessageOutput;
|
||||
} = {},
|
||||
) {
|
||||
return chatMachine.provide({
|
||||
@@ -124,13 +128,20 @@ function createTestChatMachine(
|
||||
newOffset: 0,
|
||||
...options.unlockHistoryOutput,
|
||||
})),
|
||||
unlockMessage: fromPromise<UnlockMessageOutput, { messageId: string }>(
|
||||
async ({ input }) =>
|
||||
options.unlockMessageOutput ?? {
|
||||
messageId: input.messageId,
|
||||
unlockMessage: fromPromise<
|
||||
MachineUnlockMessageOutput,
|
||||
UnlockMessageRequest
|
||||
>(async ({ input }) => {
|
||||
const output = options.unlockMessageOutput ?? {
|
||||
messageId: input.messageId ?? input.displayMessageId,
|
||||
response: makeUnlockPrivateResponse(),
|
||||
},
|
||||
),
|
||||
};
|
||||
return {
|
||||
displayMessageId: input.displayMessageId,
|
||||
request: input,
|
||||
response: output.response,
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -217,6 +228,45 @@ describe("chatMachine transitions", () => {
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("keeps a promotion separate from normal history", async () => {
|
||||
const actor = createActor(
|
||||
createTestChatMachine({
|
||||
historyMessages: [
|
||||
{
|
||||
id: "history-1",
|
||||
content: "Existing history",
|
||||
isFromAI: true,
|
||||
date: "2026-07-13",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).start();
|
||||
|
||||
actor.send({ type: "ChatUserLogin", token: "token" });
|
||||
await waitFor(actor, (snapshot) =>
|
||||
snapshot.matches({ userSession: "ready" }),
|
||||
);
|
||||
actor.send({
|
||||
type: "ChatPromotionInjected",
|
||||
promotion: {
|
||||
promotionType: "voice",
|
||||
lockType: "voice_message",
|
||||
clientLockId: "promotion-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const context = actor.getSnapshot().context;
|
||||
expect(context.messages).toHaveLength(1);
|
||||
expect(context.promotion?.message).toMatchObject({
|
||||
id: "promotion:promotion-1",
|
||||
locked: true,
|
||||
lockReason: "voice_message",
|
||||
});
|
||||
|
||||
actor.stop();
|
||||
});
|
||||
|
||||
it("renders local history before network history sync finishes", async () => {
|
||||
let resolveNetwork!: () => void;
|
||||
const networkReleased = new Promise<void>((resolve) => {
|
||||
@@ -586,7 +636,7 @@ describe("chatMachine transitions", () => {
|
||||
unlockMessageOutput: {
|
||||
messageId: "msg-private-locked",
|
||||
response: makeUnlockPrivateResponse({
|
||||
content: "This response content must be ignored.",
|
||||
content: "Unlocked private message content.",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
@@ -612,7 +662,7 @@ describe("chatMachine transitions", () => {
|
||||
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||
{
|
||||
id: "msg-private-locked",
|
||||
content: "Original private message content.",
|
||||
content: "Unlocked private message content.",
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
lockedPrivate: false,
|
||||
@@ -647,7 +697,7 @@ describe("chatMachine transitions", () => {
|
||||
unlockMessageOutput: {
|
||||
messageId: "msg-shared-id",
|
||||
response: makeUnlockPrivateResponse({
|
||||
content: "This response content must be ignored.",
|
||||
content: "Unlocked private AI message.",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
@@ -676,7 +726,7 @@ describe("chatMachine transitions", () => {
|
||||
},
|
||||
{
|
||||
id: "msg-shared-id",
|
||||
content: "Original AI private message",
|
||||
content: "Unlocked private AI message.",
|
||||
isFromAI: true,
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
@@ -848,6 +898,7 @@ describe("chatMachine transitions", () => {
|
||||
);
|
||||
|
||||
expect(actor.getSnapshot().context.unlockPaywallRequest).toEqual({
|
||||
displayMessageId: "msg-voice-locked",
|
||||
messageId: "msg-voice-locked",
|
||||
kind: "voice",
|
||||
reason: "insufficient_balance",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { UnlockPrivateResponse } from "@/data/dto/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
import {
|
||||
appendPromotionMessage,
|
||||
applyPromotionUnlockOutput,
|
||||
createChatPromotionState,
|
||||
} from "@/stores/chat/chat-promotion";
|
||||
|
||||
const promotion: PendingChatPromotion = {
|
||||
promotionType: "image",
|
||||
lockType: "image_paywall",
|
||||
clientLockId: "promotion-1",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
describe("chat promotion", () => {
|
||||
it("creates the requested locked message and appends it at the tail", () => {
|
||||
const state = createChatPromotionState(promotion);
|
||||
const messages = appendPromotionMessage(
|
||||
[
|
||||
{
|
||||
id: "history-1",
|
||||
content: "History",
|
||||
isFromAI: true,
|
||||
date: "2026-07-13",
|
||||
},
|
||||
],
|
||||
state,
|
||||
);
|
||||
|
||||
expect(messages.map((message) => message.id)).toEqual([
|
||||
"history-1",
|
||||
"promotion:promotion-1",
|
||||
]);
|
||||
expect(messages[1]).toMatchObject({
|
||||
locked: true,
|
||||
lockReason: "image_paywall",
|
||||
imagePaywalled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces a temporary promotion with the real unlocked image", () => {
|
||||
const state = createChatPromotionState(promotion);
|
||||
const next = applyPromotionUnlockOutput(state, {
|
||||
displayMessageId: "promotion:promotion-1",
|
||||
request: {
|
||||
displayMessageId: "promotion:promotion-1",
|
||||
lockType: "image_paywall",
|
||||
clientLockId: "promotion-1",
|
||||
},
|
||||
response: UnlockPrivateResponse.from({
|
||||
unlocked: true,
|
||||
messageId: "backend-1",
|
||||
image: {
|
||||
type: "promotion",
|
||||
url: "https://example.com/unlocked.jpg",
|
||||
},
|
||||
lockDetail: {
|
||||
locked: false,
|
||||
showContent: true,
|
||||
showUpgrade: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(next?.message).toMatchObject({
|
||||
id: "backend-1",
|
||||
imageUrl: "https://example.com/unlocked.jpg",
|
||||
imagePaywalled: false,
|
||||
locked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the promotion last and removes matching history duplicates", () => {
|
||||
const state = createChatPromotionState(promotion, "backend-1");
|
||||
const messages = appendPromotionMessage(
|
||||
[
|
||||
{
|
||||
id: "backend-1",
|
||||
content: "Stale history copy",
|
||||
isFromAI: true,
|
||||
date: "2026-07-13",
|
||||
},
|
||||
],
|
||||
state,
|
||||
);
|
||||
|
||||
expect(messages).toEqual([state.message]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { useMachine } from "@xstate/react";
|
||||
|
||||
import { chatMachine } from "./chat-machine";
|
||||
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
||||
import { appendPromotionMessage } from "./chat-promotion";
|
||||
|
||||
/**
|
||||
* 对外暴露的 State 形状
|
||||
@@ -20,6 +21,8 @@ import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
||||
*/
|
||||
interface ChatState {
|
||||
messages: MachineContext["messages"];
|
||||
historyMessages: MachineContext["messages"];
|
||||
promotion: MachineContext["promotion"];
|
||||
isReplyingAI: boolean;
|
||||
upgradePromptVisible: boolean;
|
||||
upgradeReason: MachineContext["upgradeReason"];
|
||||
@@ -51,7 +54,12 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
||||
// 映射 XState 状态机快照 → 原 ChatState 形状
|
||||
const chatState = useMemo<ChatState>(
|
||||
() => ({
|
||||
messages: state.context.messages,
|
||||
messages: appendPromotionMessage(
|
||||
state.context.messages,
|
||||
state.context.promotion,
|
||||
),
|
||||
historyMessages: state.context.messages,
|
||||
promotion: state.context.promotion,
|
||||
isReplyingAI: state.context.isReplyingAI,
|
||||
upgradePromptVisible: state.context.upgradePromptVisible,
|
||||
upgradeReason: state.context.upgradeReason,
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
* chat-screen 不直接读 AuthStorage(除 token 取值)。
|
||||
*/
|
||||
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
|
||||
export type ChatEvent =
|
||||
// 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
|
||||
@@ -35,10 +37,19 @@ export type ChatEvent =
|
||||
| { type: "ChatSendMessage"; content: string }
|
||||
| { type: "ChatSendImage"; imageBase64: string }
|
||||
| { type: "ChatLoadMoreHistory" }
|
||||
| {
|
||||
type: "ChatPromotionInjected";
|
||||
promotion: PendingChatPromotion;
|
||||
messageId?: string;
|
||||
}
|
||||
| { type: "ChatPromotionCleared" }
|
||||
| {
|
||||
type: "ChatUnlockMessageRequested";
|
||||
messageId: string;
|
||||
remoteMessageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: ChatLockType;
|
||||
clientLockId?: string;
|
||||
}
|
||||
| { type: "ChatUnlockPaywallNavigationConsumed" }
|
||||
| { type: "ChatPaymentSucceeded" }
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ChatState } from "./chat-state";
|
||||
export const PAGE_SIZE = 50;
|
||||
|
||||
export * from "./chat-message-mappers";
|
||||
export * from "./chat-promotion";
|
||||
export * from "./chat-send-state";
|
||||
export * from "./chat-unlock-helpers";
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
applyNetworkHistoryLoadedOutput,
|
||||
countLockedHistoryMessages,
|
||||
shouldPromptUnlockHistory,
|
||||
createChatPromotionState,
|
||||
} from "./chat-machine.helpers";
|
||||
import {
|
||||
loadHistoryActor,
|
||||
@@ -113,18 +114,32 @@ export const chatMachine = setup({
|
||||
clearUnlockPaywallRequest: clearUnlockPaywallRequestAction,
|
||||
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
|
||||
|
||||
startGuestSession: assign(() => ({
|
||||
startGuestSession: assign(({ context }) => ({
|
||||
...initialState,
|
||||
promotion: context.promotion,
|
||||
})),
|
||||
|
||||
startUserSession: assign(() => ({
|
||||
startUserSession: assign(({ context }) => ({
|
||||
...initialState,
|
||||
promotion: context.promotion,
|
||||
})),
|
||||
|
||||
clearChatSession: assign(() => ({
|
||||
...initialState,
|
||||
})),
|
||||
|
||||
injectPromotion: assign(({ event }) => {
|
||||
if (event.type !== "ChatPromotionInjected") return {};
|
||||
return {
|
||||
promotion: createChatPromotionState(
|
||||
event.promotion,
|
||||
event.messageId,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
clearPromotion: assign({ promotion: null }),
|
||||
|
||||
applyLocalHistoryLoaded: assign(({ event }) => {
|
||||
if (event.type !== "ChatLocalHistoryLoaded") return {};
|
||||
return applyHistoryLoadedOutput(event.output);
|
||||
@@ -169,6 +184,10 @@ export const chatMachine = setup({
|
||||
id: "chat",
|
||||
initial: "idle",
|
||||
context: initialState,
|
||||
on: {
|
||||
ChatPromotionInjected: { actions: "injectPromotion" },
|
||||
ChatPromotionCleared: { actions: "clearPromotion" },
|
||||
},
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
@@ -471,7 +490,16 @@ export const chatMachine = setup({
|
||||
id: "unlockMessage",
|
||||
src: "unlockMessage",
|
||||
input: ({ context }) => ({
|
||||
messageId: context.unlockingMessageId ?? "",
|
||||
displayMessageId: context.unlockingMessageId ?? "",
|
||||
...(context.unlockingRemoteMessageId
|
||||
? { messageId: context.unlockingRemoteMessageId }
|
||||
: {}),
|
||||
...(context.unlockingLockType
|
||||
? { lockType: context.unlockingLockType }
|
||||
: {}),
|
||||
...(context.unlockingClientLockId
|
||||
? { clientLockId: context.unlockingClientLockId }
|
||||
: {}),
|
||||
}),
|
||||
onDone: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
import { todayString } from "@/utils";
|
||||
|
||||
import {
|
||||
applySingleUnlockOutput,
|
||||
type UnlockMessageOutput,
|
||||
} from "./chat-unlock-helpers";
|
||||
|
||||
const PROMOTION_COPY = {
|
||||
voice: "I left a voice message for you. Unlock it to listen.",
|
||||
image: "I sent you a private photo. Unlock it to view.",
|
||||
private: "I have something private to tell you. Unlock it to read.",
|
||||
} as const;
|
||||
|
||||
export interface ChatPromotionState {
|
||||
session: PendingChatPromotion;
|
||||
message: UiMessage;
|
||||
}
|
||||
|
||||
export function createChatPromotionState(
|
||||
session: PendingChatPromotion,
|
||||
messageId?: string,
|
||||
): ChatPromotionState {
|
||||
const isImage = session.promotionType === "image";
|
||||
const isPrivate = session.promotionType === "private";
|
||||
|
||||
return {
|
||||
session,
|
||||
message: {
|
||||
id: messageId || `promotion:${session.clientLockId}`,
|
||||
content: "",
|
||||
isFromAI: true,
|
||||
date: todayString(),
|
||||
locked: true,
|
||||
lockReason: session.lockType,
|
||||
imagePaywalled: isImage ? true : undefined,
|
||||
lockedPrivate: isPrivate ? true : undefined,
|
||||
privateMessageHint: PROMOTION_COPY[session.promotionType],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function appendPromotionMessage(
|
||||
messages: readonly UiMessage[],
|
||||
promotion: ChatPromotionState | null,
|
||||
): UiMessage[] {
|
||||
if (!promotion) return [...messages];
|
||||
return [
|
||||
...messages.filter((message) => message.id !== promotion.message.id),
|
||||
promotion.message,
|
||||
];
|
||||
}
|
||||
|
||||
export function applyPromotionUnlockOutput(
|
||||
promotion: ChatPromotionState | null,
|
||||
output: UnlockMessageOutput,
|
||||
): ChatPromotionState | null {
|
||||
if (!promotion || promotion.message.id !== output.displayMessageId) {
|
||||
return promotion;
|
||||
}
|
||||
return {
|
||||
...promotion,
|
||||
message:
|
||||
applySingleUnlockOutput([promotion.message], output)[0] ??
|
||||
promotion.message,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { UiMessage } from "@/data/dto/chat";
|
||||
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
import type { PendingChatPromotion } from "@/data/storage/navigation";
|
||||
import type { ChatPromotionState } from "./chat-promotion";
|
||||
|
||||
export type ChatUpgradeReason = "insufficient_credits";
|
||||
|
||||
export interface ChatUnlockPaywallRequest {
|
||||
messageId: string;
|
||||
displayMessageId: string;
|
||||
messageId?: string;
|
||||
kind: PendingChatUnlockKind;
|
||||
lockType?: ChatLockType;
|
||||
clientLockId?: string;
|
||||
promotion?: PendingChatPromotion;
|
||||
reason: string;
|
||||
creditBalance: number;
|
||||
requiredCredits: number;
|
||||
@@ -14,6 +21,7 @@ export interface ChatUnlockPaywallRequest {
|
||||
|
||||
export interface ChatState {
|
||||
messages: UiMessage[];
|
||||
promotion: ChatPromotionState | null;
|
||||
isReplyingAI: boolean;
|
||||
pendingReplyCount: number;
|
||||
upgradePromptVisible: boolean;
|
||||
@@ -40,12 +48,16 @@ export interface ChatState {
|
||||
isUnlockingMessage: boolean;
|
||||
unlockingMessageId: string | null;
|
||||
unlockingMessageKind: PendingChatUnlockKind | null;
|
||||
unlockingRemoteMessageId: string | null;
|
||||
unlockingLockType: ChatLockType | null;
|
||||
unlockingClientLockId: string | null;
|
||||
unlockMessageError: string | null;
|
||||
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
|
||||
}
|
||||
|
||||
export const initialState: ChatState = {
|
||||
messages: [],
|
||||
promotion: null,
|
||||
isReplyingAI: false,
|
||||
pendingReplyCount: 0,
|
||||
upgradePromptVisible: false,
|
||||
@@ -68,6 +80,9 @@ export const initialState: ChatState = {
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
import { readAndSyncHistory } from "./chat-history-sync";
|
||||
import {
|
||||
applySingleUnlockOutput,
|
||||
applyPromotionUnlockOutput,
|
||||
countLockedHistoryMessages,
|
||||
type UnlockMessageRequest,
|
||||
type UnlockMessageOutput,
|
||||
} from "./chat-machine.helpers";
|
||||
|
||||
@@ -49,24 +51,32 @@ export const unlockHistoryActor = fromPromise<{
|
||||
|
||||
export const unlockMessageActor = fromPromise<
|
||||
UnlockMessageOutput,
|
||||
{ messageId: string }
|
||||
UnlockMessageRequest
|
||||
>(async ({ input }) => {
|
||||
const chatRepo = getChatRepository();
|
||||
const unlockResult = await chatRepo.unlockPrivateMessage(input.messageId);
|
||||
const unlockResult = await chatRepo.unlockPrivateMessage({
|
||||
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||
...(input.lockType ? { lockType: input.lockType } : {}),
|
||||
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
||||
});
|
||||
if (Result.isErr(unlockResult)) {
|
||||
log.error("[chat-machine] unlockMessageActor failed", {
|
||||
messageId: input.messageId,
|
||||
clientLockId: input.clientLockId,
|
||||
error: unlockResult.error,
|
||||
});
|
||||
throw unlockResult.error;
|
||||
}
|
||||
|
||||
if (unlockResult.data.unlocked) {
|
||||
if (unlockResult.data.unlocked && input.messageId && !input.clientLockId) {
|
||||
const markResult = await chatRepo.markPrivateMessageUnlockedInLocal(
|
||||
input.messageId,
|
||||
{
|
||||
content: unlockResult.data.content,
|
||||
audioUrl: unlockResult.data.audioUrl,
|
||||
...(unlockResult.data.image.url
|
||||
? { image: unlockResult.data.image }
|
||||
: {}),
|
||||
lockDetail: unlockResult.data.lockDetail,
|
||||
},
|
||||
);
|
||||
@@ -79,7 +89,8 @@ export const unlockMessageActor = fromPromise<
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: input.messageId,
|
||||
displayMessageId: input.displayMessageId,
|
||||
request: input,
|
||||
response: unlockResult.data,
|
||||
};
|
||||
});
|
||||
@@ -124,6 +135,10 @@ export const markUnlockMessageStartedAction = chatAssign(
|
||||
isUnlockingMessage: true,
|
||||
unlockingMessageId: event.messageId,
|
||||
unlockingMessageKind: event.kind,
|
||||
unlockingRemoteMessageId:
|
||||
event.remoteMessageId ?? (event.lockType ? null : event.messageId),
|
||||
unlockingLockType: event.lockType ?? null,
|
||||
unlockingClientLockId: event.clientLockId ?? null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
};
|
||||
@@ -136,9 +151,13 @@ export const applyUnlockMessageSucceededAction = chatAssign(
|
||||
if (!output) return {};
|
||||
return {
|
||||
messages: applySingleUnlockOutput(context.messages, output),
|
||||
promotion: applyPromotionUnlockOutput(context.promotion, output),
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: null,
|
||||
unlockPaywallRequest: null,
|
||||
creditBalance: output.response.creditBalance,
|
||||
@@ -154,13 +173,33 @@ export const requestUnlockPaymentFromOutputAction = chatAssign(
|
||||
const output = (event as { output?: UnlockMessageOutput }).output;
|
||||
if (!output) return {};
|
||||
return {
|
||||
promotion: applyPromotionUnlockOutput(context.promotion, output),
|
||||
isUnlockingMessage: false,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
unlockMessageError: output.response.reason,
|
||||
unlockPaywallRequest: {
|
||||
messageId: output.messageId,
|
||||
displayMessageId:
|
||||
output.response.messageId || output.displayMessageId,
|
||||
...(output.response.messageId || output.request.messageId
|
||||
? {
|
||||
messageId:
|
||||
output.response.messageId || output.request.messageId,
|
||||
}
|
||||
: {}),
|
||||
kind: context.unlockingMessageKind ?? "private",
|
||||
...(output.request.lockType
|
||||
? { lockType: output.request.lockType }
|
||||
: {}),
|
||||
...(output.request.clientLockId
|
||||
? { clientLockId: output.request.clientLockId }
|
||||
: {}),
|
||||
...(context.promotion?.message.id === output.displayMessageId
|
||||
? { promotion: context.promotion.session }
|
||||
: {}),
|
||||
reason: output.response.reason,
|
||||
creditBalance: output.response.creditBalance,
|
||||
requiredCredits: output.response.requiredCredits,
|
||||
@@ -180,8 +219,20 @@ export const requestUnlockPaymentFromErrorAction = chatAssign(
|
||||
unlockPaywallRequest:
|
||||
context.unlockingMessageId && context.unlockingMessageKind
|
||||
? {
|
||||
messageId: context.unlockingMessageId,
|
||||
displayMessageId: context.unlockingMessageId,
|
||||
...(context.unlockingRemoteMessageId
|
||||
? { messageId: context.unlockingRemoteMessageId }
|
||||
: {}),
|
||||
kind: context.unlockingMessageKind,
|
||||
...(context.unlockingLockType
|
||||
? { lockType: context.unlockingLockType }
|
||||
: {}),
|
||||
...(context.unlockingClientLockId
|
||||
? { clientLockId: context.unlockingClientLockId }
|
||||
: {}),
|
||||
...(context.promotion?.message.id === context.unlockingMessageId
|
||||
? { promotion: context.promotion.session }
|
||||
: {}),
|
||||
reason: "unlock_failed",
|
||||
creditBalance: context.creditBalance,
|
||||
requiredCredits: context.requiredCredits,
|
||||
@@ -190,6 +241,9 @@ export const requestUnlockPaymentFromErrorAction = chatAssign(
|
||||
: null,
|
||||
unlockingMessageId: null,
|
||||
unlockingMessageKind: null,
|
||||
unlockingRemoteMessageId: null,
|
||||
unlockingLockType: null,
|
||||
unlockingClientLockId: null,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { UiMessage, UnlockPrivateResponse } from "@/data/dto/chat";
|
||||
import type { ChatLockType } from "@/data/schemas/chat";
|
||||
|
||||
export interface UnlockMessageRequest {
|
||||
displayMessageId: string;
|
||||
messageId?: string;
|
||||
lockType?: ChatLockType;
|
||||
clientLockId?: string;
|
||||
}
|
||||
|
||||
export interface UnlockMessageOutput {
|
||||
messageId: string;
|
||||
displayMessageId: string;
|
||||
request: UnlockMessageRequest;
|
||||
response: UnlockPrivateResponse;
|
||||
}
|
||||
|
||||
@@ -13,17 +22,35 @@ export function applySingleUnlockOutput(
|
||||
messages: readonly UiMessage[],
|
||||
output: UnlockMessageOutput,
|
||||
): UiMessage[] {
|
||||
if (!output.response.unlocked) return [...messages];
|
||||
|
||||
return messages.map((message) => {
|
||||
if (!shouldApplySingleUnlock(message, output.messageId)) return message;
|
||||
if (!shouldApplySingleUnlock(message, output.displayMessageId)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const resolvedId = output.response.messageId || message.id;
|
||||
if (!output.response.unlocked) {
|
||||
return {
|
||||
...message,
|
||||
id: resolvedId,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedImageUrl =
|
||||
output.response.image.url ?? message.imageUrl;
|
||||
const resolvedContent =
|
||||
message.lockReason === "private_message"
|
||||
? output.response.content || message.content
|
||||
: message.content;
|
||||
|
||||
return {
|
||||
...message,
|
||||
id: resolvedId,
|
||||
content: resolvedContent,
|
||||
audioUrl: getUnlockedAudioUrl(message, output.response),
|
||||
imageUrl: resolvedImageUrl,
|
||||
locked: output.response.lockDetail.locked,
|
||||
lockReason: output.response.lockDetail.reason,
|
||||
imagePaywalled: message.imageUrl
|
||||
imagePaywalled: resolvedImageUrl
|
||||
? output.response.lockDetail.locked &&
|
||||
output.response.lockDetail.showUpgrade
|
||||
: undefined,
|
||||
@@ -48,6 +75,8 @@ function shouldApplySingleUnlock(message: UiMessage, messageId: string): boolean
|
||||
if (message.locked !== true) return false;
|
||||
return (
|
||||
message.imagePaywalled === true ||
|
||||
message.lockReason === "image_paywall" ||
|
||||
message.lockReason === "image" ||
|
||||
message.lockReason === "private_message" ||
|
||||
message.lockReason === "voice_message"
|
||||
);
|
||||
@@ -61,6 +90,8 @@ function isUnlockableLockedMessage(message: UiMessage): boolean {
|
||||
if (!message.isFromAI || message.locked !== true) return false;
|
||||
if (message.imagePaywalled === true) return true;
|
||||
return (
|
||||
message.lockReason === "image_paywall" ||
|
||||
message.lockReason === "image" ||
|
||||
message.lockReason === "private_message" ||
|
||||
message.lockReason === "voice_message"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user