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 { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-flow";
|
||||||
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
|
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
|
||||||
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
|
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";
|
import styles from "./components/chat-screen.module.css";
|
||||||
|
|
||||||
export function ChatScreen() {
|
export function ChatScreen() {
|
||||||
@@ -42,6 +43,10 @@ export function ChatScreen() {
|
|||||||
const state = useChatState();
|
const state = useChatState();
|
||||||
const authState = useAuthState();
|
const authState = useAuthState();
|
||||||
const authDispatch = useAuthDispatch();
|
const authDispatch = useAuthDispatch();
|
||||||
|
const isPromotionBootstrapReady = useChatPromotionBootstrap();
|
||||||
|
const visibleMessages = isPromotionBootstrapReady
|
||||||
|
? state.messages
|
||||||
|
: state.historyMessages;
|
||||||
const imageMessageId = getChatImageOverlayMessageId(searchParams);
|
const imageMessageId = getChatImageOverlayMessageId(searchParams);
|
||||||
const imageReturnUrl = imageMessageId
|
const imageReturnUrl = imageMessageId
|
||||||
? buildChatImageOverlayUrl(imageMessageId)
|
? buildChatImageOverlayUrl(imageMessageId)
|
||||||
@@ -54,6 +59,8 @@ export function ChatScreen() {
|
|||||||
} = useChatUnlockNavigationFlow({
|
} = useChatUnlockNavigationFlow({
|
||||||
returnUrl: ROUTES.chat,
|
returnUrl: ROUTES.chat,
|
||||||
ignoredKind: "image",
|
ignoredKind: "image",
|
||||||
|
promotionScope: "exclude",
|
||||||
|
enabled: isPromotionBootstrapReady,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
unlockPaywallRequest: imageUnlockPaywallRequest,
|
unlockPaywallRequest: imageUnlockPaywallRequest,
|
||||||
@@ -64,16 +71,28 @@ export function ChatScreen() {
|
|||||||
returnUrl: imageReturnUrl,
|
returnUrl: imageReturnUrl,
|
||||||
expectedKind: "image",
|
expectedKind: "image",
|
||||||
expectedMessageId: imageMessageId ?? undefined,
|
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(
|
const selectedImageMessage = useMemo(
|
||||||
() =>
|
() =>
|
||||||
imageMessageId
|
imageMessageId
|
||||||
? state.messages.find(
|
? visibleMessages.find(
|
||||||
(item) => item.id === imageMessageId && item.imageUrl,
|
(item) => item.id === imageMessageId && item.imageUrl,
|
||||||
) ?? null
|
) ?? null
|
||||||
: null,
|
: null,
|
||||||
[imageMessageId, state.messages],
|
[imageMessageId, visibleMessages],
|
||||||
);
|
);
|
||||||
|
|
||||||
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
|
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
|
||||||
@@ -89,7 +108,8 @@ export function ChatScreen() {
|
|||||||
historyLoaded: state.historyLoaded,
|
historyLoaded: state.historyLoaded,
|
||||||
loginStatus: authState.loginStatus,
|
loginStatus: authState.loginStatus,
|
||||||
});
|
});
|
||||||
const shouldShowPwaInstall = state.historyLoaded && state.messages.length >= 10;
|
const shouldShowPwaInstall =
|
||||||
|
state.historyLoaded && state.historyMessages.length >= 10;
|
||||||
const shouldShowBrowserHint = shouldStartExternalBrowserPrompt({
|
const shouldShowBrowserHint = shouldStartExternalBrowserPrompt({
|
||||||
hasInitialized: authState.hasInitialized,
|
hasInitialized: authState.hasInitialized,
|
||||||
isLoading: authState.isLoading,
|
isLoading: authState.isLoading,
|
||||||
@@ -119,13 +139,38 @@ export function ChatScreen() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
function handleUnlockPrivateMessage(messageId: string): void {
|
function handleUnlockPrivateMessage(messageId: string): void {
|
||||||
|
if (requestPromotionMessageUnlock(messageId, "private")) return;
|
||||||
requestMessageUnlock(messageId, "private");
|
requestMessageUnlock(messageId, "private");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUnlockVoiceMessage(messageId: string): void {
|
function handleUnlockVoiceMessage(messageId: string): void {
|
||||||
|
if (requestPromotionMessageUnlock(messageId, "voice")) return;
|
||||||
requestMessageUnlock(messageId, "voice");
|
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 {
|
function handleOpenImage(messageId: string): void {
|
||||||
router.push(buildChatImageOverlayUrl(messageId), { scroll: false });
|
router.push(buildChatImageOverlayUrl(messageId), { scroll: false });
|
||||||
}
|
}
|
||||||
@@ -169,12 +214,13 @@ export function ChatScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatArea
|
<ChatArea
|
||||||
messages={state.messages}
|
messages={visibleMessages}
|
||||||
isReplyingAI={state.isReplyingAI}
|
isReplyingAI={state.isReplyingAI}
|
||||||
isUnlockingMessage={state.isUnlockingMessage}
|
isUnlockingMessage={state.isUnlockingMessage}
|
||||||
unlockingMessageId={state.unlockingMessageId}
|
unlockingMessageId={state.unlockingMessageId}
|
||||||
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
onUnlockPrivateMessage={handleUnlockPrivateMessage}
|
||||||
onUnlockVoiceMessage={handleUnlockVoiceMessage}
|
onUnlockVoiceMessage={handleUnlockVoiceMessage}
|
||||||
|
onUnlockImageMessage={handleUnlockImageMessage}
|
||||||
onOpenImage={handleOpenImage}
|
onOpenImage={handleOpenImage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -201,8 +247,19 @@ export function ChatScreen() {
|
|||||||
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
|
onCloseInsufficientCreditsDialog={closeInsufficientCreditsDialog}
|
||||||
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
|
onConfirmInsufficientCreditsDialog={confirmInsufficientCreditsDialog}
|
||||||
/>
|
/>
|
||||||
|
<ChatUnlockDialogs
|
||||||
|
unlockPaywallRequest={promotionUnlockPaywallRequest}
|
||||||
|
includeHistoryUnlock={false}
|
||||||
|
onCloseInsufficientCreditsDialog={
|
||||||
|
closePromotionInsufficientCreditsDialog
|
||||||
|
}
|
||||||
|
onConfirmInsufficientCreditsDialog={
|
||||||
|
confirmPromotionInsufficientCreditsDialog
|
||||||
|
}
|
||||||
|
/>
|
||||||
<ChatUnlockDialogs
|
<ChatUnlockDialogs
|
||||||
unlockPaywallRequest={imageUnlockPaywallRequest}
|
unlockPaywallRequest={imageUnlockPaywallRequest}
|
||||||
|
includeHistoryUnlock={false}
|
||||||
onCloseInsufficientCreditsDialog={closeImageInsufficientCreditsDialog}
|
onCloseInsufficientCreditsDialog={closeImageInsufficientCreditsDialog}
|
||||||
onConfirmInsufficientCreditsDialog={
|
onConfirmInsufficientCreditsDialog={
|
||||||
confirmImageInsufficientCreditsDialog
|
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;
|
unlockingMessageId?: string | null;
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||||
|
onUnlockImageMessage?: (messageId: string) => void;
|
||||||
onOpenImage?: (messageId: string) => void;
|
onOpenImage?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ export function ChatArea({
|
|||||||
unlockingMessageId,
|
unlockingMessageId,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
|
onUnlockImageMessage,
|
||||||
onOpenImage,
|
onOpenImage,
|
||||||
}: ChatAreaProps) {
|
}: ChatAreaProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -98,6 +100,7 @@ export function ChatArea({
|
|||||||
unlockingMessageId,
|
unlockingMessageId,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
|
onUnlockImageMessage,
|
||||||
onOpenImage,
|
onOpenImage,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -120,6 +123,7 @@ function renderMessagesWithDateHeaders(
|
|||||||
unlockingMessageId?: string | null,
|
unlockingMessageId?: string | null,
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void,
|
onUnlockPrivateMessage?: (messageId: string) => void,
|
||||||
onUnlockVoiceMessage?: (messageId: string) => void,
|
onUnlockVoiceMessage?: (messageId: string) => void,
|
||||||
|
onUnlockImageMessage?: (messageId: string) => void,
|
||||||
onOpenImage?: (messageId: string) => void,
|
onOpenImage?: (messageId: string) => void,
|
||||||
) {
|
) {
|
||||||
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
return buildChatRenderItems(messages, getMessageKey).map((item) =>
|
||||||
@@ -144,6 +148,7 @@ function renderMessagesWithDateHeaders(
|
|||||||
}
|
}
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
|
onUnlockImageMessage={onUnlockImageMessage}
|
||||||
onOpenImage={onOpenImage}
|
onOpenImage={onOpenImage}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,26 +10,30 @@ export interface ChatUnlockDialogsProps {
|
|||||||
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
|
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
|
||||||
onCloseInsufficientCreditsDialog: () => void;
|
onCloseInsufficientCreditsDialog: () => void;
|
||||||
onConfirmInsufficientCreditsDialog: () => void;
|
onConfirmInsufficientCreditsDialog: () => void;
|
||||||
|
includeHistoryUnlock?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatUnlockDialogs({
|
export function ChatUnlockDialogs({
|
||||||
unlockPaywallRequest,
|
unlockPaywallRequest,
|
||||||
onCloseInsufficientCreditsDialog,
|
onCloseInsufficientCreditsDialog,
|
||||||
onConfirmInsufficientCreditsDialog,
|
onConfirmInsufficientCreditsDialog,
|
||||||
|
includeHistoryUnlock = true,
|
||||||
}: ChatUnlockDialogsProps) {
|
}: ChatUnlockDialogsProps) {
|
||||||
const chatState = useChatState();
|
const chatState = useChatState();
|
||||||
const chatDispatch = useChatDispatch();
|
const chatDispatch = useChatDispatch();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HistoryUnlockDialog
|
{includeHistoryUnlock ? (
|
||||||
open={chatState.unlockHistoryPromptVisible}
|
<HistoryUnlockDialog
|
||||||
lockedCount={chatState.lockedHistoryCount}
|
open={chatState.unlockHistoryPromptVisible}
|
||||||
isLoading={chatState.isUnlockingHistory}
|
lockedCount={chatState.lockedHistoryCount}
|
||||||
errorMessage={chatState.unlockHistoryError}
|
isLoading={chatState.isUnlockingHistory}
|
||||||
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
|
errorMessage={chatState.unlockHistoryError}
|
||||||
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
|
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
|
||||||
/>
|
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<InsufficientCreditsDialog
|
<InsufficientCreditsDialog
|
||||||
open={unlockPaywallRequest !== null}
|
open={unlockPaywallRequest !== null}
|
||||||
creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
|
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;
|
isUnlockingMessage?: boolean;
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||||
|
onUnlockImageMessage?: (messageId: string) => void;
|
||||||
onOpenImage?: (messageId: string) => void;
|
onOpenImage?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ export function MessageBubble({
|
|||||||
isUnlockingMessage,
|
isUnlockingMessage,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
|
onUnlockImageMessage,
|
||||||
onOpenImage,
|
onOpenImage,
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const { avatarUrl } = useUserState();
|
const { avatarUrl } = useUserState();
|
||||||
@@ -72,6 +74,7 @@ export function MessageBubble({
|
|||||||
isUnlockingMessage={isUnlockingMessage}
|
isUnlockingMessage={isUnlockingMessage}
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
|
onUnlockImageMessage={onUnlockImageMessage}
|
||||||
onOpenImage={onOpenImage}
|
onOpenImage={onOpenImage}
|
||||||
/>
|
/>
|
||||||
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
<div className={styles.bubbleAvatarSpacer} aria-hidden="true" />
|
||||||
@@ -100,6 +103,7 @@ export function MessageBubble({
|
|||||||
isUnlockingMessage={isUnlockingMessage}
|
isUnlockingMessage={isUnlockingMessage}
|
||||||
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
onUnlockPrivateMessage={onUnlockPrivateMessage}
|
||||||
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
onUnlockVoiceMessage={onUnlockVoiceMessage}
|
||||||
|
onUnlockImageMessage={onUnlockImageMessage}
|
||||||
onOpenImage={onOpenImage}
|
onOpenImage={onOpenImage}
|
||||||
/>
|
/>
|
||||||
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
<div className={styles.bubbleInlineSpacer} aria-hidden="true" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ImageBubble } from "./image-bubble";
|
import { ImageBubble } from "./image-bubble";
|
||||||
|
import { LockedImageMessageCard } from "./locked-image-message-card";
|
||||||
import { PrivateMessageCard } from "./private-message-card";
|
import { PrivateMessageCard } from "./private-message-card";
|
||||||
import { TextBubble } from "./text-bubble";
|
import { TextBubble } from "./text-bubble";
|
||||||
import { VoiceBubble } from "./voice-bubble";
|
import { VoiceBubble } from "./voice-bubble";
|
||||||
@@ -19,6 +20,7 @@ export interface MessageContentProps {
|
|||||||
isUnlockingMessage?: boolean;
|
isUnlockingMessage?: boolean;
|
||||||
onUnlockPrivateMessage?: (messageId: string) => void;
|
onUnlockPrivateMessage?: (messageId: string) => void;
|
||||||
onUnlockVoiceMessage?: (messageId: string) => void;
|
onUnlockVoiceMessage?: (messageId: string) => void;
|
||||||
|
onUnlockImageMessage?: (messageId: string) => void;
|
||||||
onOpenImage?: (messageId: string) => void;
|
onOpenImage?: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ export function MessageContent({
|
|||||||
isUnlockingMessage,
|
isUnlockingMessage,
|
||||||
onUnlockPrivateMessage,
|
onUnlockPrivateMessage,
|
||||||
onUnlockVoiceMessage,
|
onUnlockVoiceMessage,
|
||||||
|
onUnlockImageMessage,
|
||||||
onOpenImage,
|
onOpenImage,
|
||||||
}: MessageContentProps) {
|
}: MessageContentProps) {
|
||||||
const hasImage = imageUrl != null && imageUrl.length > 0;
|
const hasImage = imageUrl != null && imageUrl.length > 0;
|
||||||
@@ -47,6 +50,9 @@ export function MessageContent({
|
|||||||
lockedPrivate === true ||
|
lockedPrivate === true ||
|
||||||
(locked === true && lockReason === "private_message");
|
(locked === true && lockReason === "private_message");
|
||||||
const isLockedVoiceMessage = locked === true && lockReason === "voice_message";
|
const isLockedVoiceMessage = locked === true && lockReason === "voice_message";
|
||||||
|
const isLockedImageMessage =
|
||||||
|
locked === true &&
|
||||||
|
(lockReason === "image_paywall" || lockReason === "image");
|
||||||
const shouldRenderVoiceMessage = hasAudio || isLockedVoiceMessage;
|
const shouldRenderVoiceMessage = hasAudio || isLockedVoiceMessage;
|
||||||
const handleUnlockPrivateMessage =
|
const handleUnlockPrivateMessage =
|
||||||
messageId && onUnlockPrivateMessage
|
messageId && onUnlockPrivateMessage
|
||||||
@@ -56,6 +62,10 @@ export function MessageContent({
|
|||||||
isLockedVoiceMessage && messageId && onUnlockVoiceMessage
|
isLockedVoiceMessage && messageId && onUnlockVoiceMessage
|
||||||
? () => onUnlockVoiceMessage(messageId)
|
? () => onUnlockVoiceMessage(messageId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const handleUnlockImageMessage =
|
||||||
|
isLockedImageMessage && messageId && onUnlockImageMessage
|
||||||
|
? () => onUnlockImageMessage(messageId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -70,6 +80,12 @@ export function MessageContent({
|
|||||||
isUnlocking={isUnlockingMessage}
|
isUnlocking={isUnlockingMessage}
|
||||||
onUnlock={handleUnlockPrivateMessage}
|
onUnlock={handleUnlockPrivateMessage}
|
||||||
/>
|
/>
|
||||||
|
) : isLockedImageMessage && !hasImage ? (
|
||||||
|
<LockedImageMessageCard
|
||||||
|
hint={privateMessageHint}
|
||||||
|
isUnlocking={isUnlockingMessage}
|
||||||
|
onUnlock={handleUnlockImageMessage}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{hasImage && imageUrl && (
|
{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 { useEffect } from "react";
|
||||||
|
|
||||||
|
import type { ChatLockType } from "@/data/schemas/chat";
|
||||||
import {
|
import {
|
||||||
consumePendingChatUnlock,
|
consumePendingChatUnlock,
|
||||||
peekPendingChatUnlock,
|
peekPendingChatUnlock,
|
||||||
type PendingChatUnlock,
|
type PendingChatUnlock,
|
||||||
type PendingChatUnlockKind,
|
type PendingChatUnlockKind,
|
||||||
|
type PendingChatPromotion,
|
||||||
} from "@/lib/navigation/chat_unlock_session";
|
} from "@/lib/navigation/chat_unlock_session";
|
||||||
import { useAppNavigator } from "@/router/use-app-navigator";
|
import { useAppNavigator } from "@/router/use-app-navigator";
|
||||||
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
|
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";
|
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 {
|
export interface UseChatUnlockNavigationFlowInput {
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
expectedKind?: PendingChatUnlockKind;
|
expectedKind?: PendingChatUnlockKind;
|
||||||
expectedMessageId?: string;
|
expectedMessageId?: string;
|
||||||
ignoredKind?: PendingChatUnlockKind;
|
ignoredKind?: PendingChatUnlockKind;
|
||||||
|
promotionScope?: PromotionScope;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +40,7 @@ export interface UseChatUnlockNavigationFlowOutput {
|
|||||||
requestMessageUnlock: (
|
requestMessageUnlock: (
|
||||||
messageId: string,
|
messageId: string,
|
||||||
kind: PendingChatUnlockKind,
|
kind: PendingChatUnlockKind,
|
||||||
|
options?: MessageUnlockOptions,
|
||||||
) => void;
|
) => void;
|
||||||
closeInsufficientCreditsDialog: () => void;
|
closeInsufficientCreditsDialog: () => void;
|
||||||
confirmInsufficientCreditsDialog: () => void;
|
confirmInsufficientCreditsDialog: () => void;
|
||||||
@@ -38,6 +51,7 @@ export function useChatUnlockNavigationFlow({
|
|||||||
expectedKind,
|
expectedKind,
|
||||||
expectedMessageId,
|
expectedMessageId,
|
||||||
ignoredKind,
|
ignoredKind,
|
||||||
|
promotionScope = "any",
|
||||||
enabled = true,
|
enabled = true,
|
||||||
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
|
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
|
||||||
const navigator = useAppNavigator();
|
const navigator = useAppNavigator();
|
||||||
@@ -49,6 +63,7 @@ export function useChatUnlockNavigationFlow({
|
|||||||
expectedKind,
|
expectedKind,
|
||||||
expectedMessageId,
|
expectedMessageId,
|
||||||
ignoredKind,
|
ignoredKind,
|
||||||
|
promotionScope,
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,6 +83,7 @@ export function useChatUnlockNavigationFlow({
|
|||||||
pending,
|
pending,
|
||||||
expectedKind,
|
expectedKind,
|
||||||
expectedMessageId,
|
expectedMessageId,
|
||||||
|
promotionScope,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -78,8 +94,11 @@ export function useChatUnlockNavigationFlow({
|
|||||||
|
|
||||||
chatDispatch({
|
chatDispatch({
|
||||||
type: "ChatUnlockMessageRequested",
|
type: "ChatUnlockMessageRequested",
|
||||||
messageId: consumed.messageId,
|
messageId: consumed.displayMessageId,
|
||||||
|
remoteMessageId: consumed.messageId,
|
||||||
kind: consumed.kind,
|
kind: consumed.kind,
|
||||||
|
lockType: consumed.lockType,
|
||||||
|
clientLockId: consumed.clientLockId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,24 +112,34 @@ export function useChatUnlockNavigationFlow({
|
|||||||
enabled,
|
enabled,
|
||||||
expectedKind,
|
expectedKind,
|
||||||
expectedMessageId,
|
expectedMessageId,
|
||||||
ignoredKind,
|
|
||||||
navigator.isAuthenticatedUser,
|
navigator.isAuthenticatedUser,
|
||||||
|
promotionScope,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function requestMessageUnlock(
|
function requestMessageUnlock(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
kind: PendingChatUnlockKind,
|
kind: PendingChatUnlockKind,
|
||||||
|
options: MessageUnlockOptions = {},
|
||||||
): void {
|
): void {
|
||||||
|
const remoteMessageId =
|
||||||
|
options.remoteMessageId ?? (options.lockType ? undefined : messageId);
|
||||||
navigator.startMessageUnlock({
|
navigator.startMessageUnlock({
|
||||||
messageId,
|
displayMessageId: messageId,
|
||||||
|
messageId: remoteMessageId,
|
||||||
kind,
|
kind,
|
||||||
|
lockType: options.lockType,
|
||||||
|
clientLockId: options.clientLockId,
|
||||||
|
promotion: options.promotion,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
onAuthenticated: () => {
|
onAuthenticated: () => {
|
||||||
chatDispatch({
|
chatDispatch({
|
||||||
type: "ChatUnlockMessageRequested",
|
type: "ChatUnlockMessageRequested",
|
||||||
messageId,
|
messageId,
|
||||||
|
remoteMessageId,
|
||||||
kind,
|
kind,
|
||||||
|
lockType: options.lockType,
|
||||||
|
clientLockId: options.clientLockId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -125,8 +154,12 @@ export function useChatUnlockNavigationFlow({
|
|||||||
|
|
||||||
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
|
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
|
||||||
navigator.openSubscriptionForPendingUnlock({
|
navigator.openSubscriptionForPendingUnlock({
|
||||||
|
displayMessageId: unlockPaywallRequest.displayMessageId,
|
||||||
messageId: unlockPaywallRequest.messageId,
|
messageId: unlockPaywallRequest.messageId,
|
||||||
kind: unlockPaywallRequest.kind,
|
kind: unlockPaywallRequest.kind,
|
||||||
|
lockType: unlockPaywallRequest.lockType,
|
||||||
|
clientLockId: unlockPaywallRequest.clientLockId,
|
||||||
|
promotion: unlockPaywallRequest.promotion,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
type: getInsufficientCreditsSubscriptionType(userState.isVip),
|
type: getInsufficientCreditsSubscriptionType(userState.isVip),
|
||||||
});
|
});
|
||||||
@@ -145,6 +178,7 @@ function getScopedUnlockPaywallRequest(input: {
|
|||||||
expectedKind?: PendingChatUnlockKind;
|
expectedKind?: PendingChatUnlockKind;
|
||||||
expectedMessageId?: string;
|
expectedMessageId?: string;
|
||||||
ignoredKind?: PendingChatUnlockKind;
|
ignoredKind?: PendingChatUnlockKind;
|
||||||
|
promotionScope?: PromotionScope;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}): ChatUnlockPaywallRequest | null {
|
}): ChatUnlockPaywallRequest | null {
|
||||||
const {
|
const {
|
||||||
@@ -152,13 +186,21 @@ function getScopedUnlockPaywallRequest(input: {
|
|||||||
expectedKind,
|
expectedKind,
|
||||||
expectedMessageId,
|
expectedMessageId,
|
||||||
ignoredKind,
|
ignoredKind,
|
||||||
|
promotionScope = "any",
|
||||||
enabled = true,
|
enabled = true,
|
||||||
} = input;
|
} = input;
|
||||||
if (!enabled) return null;
|
if (!enabled || !request) return null;
|
||||||
if (!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 (ignoredKind && request.kind === ignoredKind) return null;
|
||||||
if (expectedKind && request.kind !== expectedKind) 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;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,10 +208,22 @@ function matchesPendingUnlockScope(input: {
|
|||||||
pending: PendingChatUnlock;
|
pending: PendingChatUnlock;
|
||||||
expectedKind?: PendingChatUnlockKind;
|
expectedKind?: PendingChatUnlockKind;
|
||||||
expectedMessageId?: string;
|
expectedMessageId?: string;
|
||||||
|
promotionScope?: PromotionScope;
|
||||||
}): boolean {
|
}): 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 (expectedKind && pending.kind !== expectedKind) return false;
|
||||||
if (expectedMessageId && pending.messageId !== expectedMessageId) {
|
if (
|
||||||
|
expectedMessageId &&
|
||||||
|
pending.displayMessageId !== expectedMessageId &&
|
||||||
|
pending.messageId !== expectedMessageId
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ import { useRouter } from "next/navigation";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
persistExternalEntryPayload,
|
persistExternalEntryPayload,
|
||||||
|
resolveExternalEntryPromotionType,
|
||||||
resolveExternalEntryTarget,
|
resolveExternalEntryTarget,
|
||||||
} from "@/lib/navigation/external_entry";
|
} from "@/lib/navigation/external_entry";
|
||||||
|
import {
|
||||||
|
clearPendingChatPromotion,
|
||||||
|
savePendingChatPromotion,
|
||||||
|
} from "@/lib/navigation/chat_unlock_session";
|
||||||
|
import { ROUTES } from "@/router/routes";
|
||||||
import { Logger } from "@/utils";
|
import { Logger } from "@/utils";
|
||||||
|
|
||||||
const log = new Logger("ExternalEntryPersist");
|
const log = new Logger("ExternalEntryPersist");
|
||||||
@@ -17,8 +23,8 @@ interface ExternalEntryPersistProps {
|
|||||||
psid: string | null;
|
psid: string | null;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
target: string | null;
|
target: string | null;
|
||||||
redirect: string | null;
|
mode: string | null;
|
||||||
next: string | null;
|
promotionType: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExternalEntryPersist({
|
export default function ExternalEntryPersist({
|
||||||
@@ -27,26 +33,31 @@ export default function ExternalEntryPersist({
|
|||||||
psid,
|
psid,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
target,
|
target,
|
||||||
redirect,
|
mode,
|
||||||
next,
|
promotionType,
|
||||||
}: ExternalEntryPersistProps) {
|
}: ExternalEntryPersistProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const destination = resolveExternalEntryTarget({
|
const destination = resolveExternalEntryTarget({ target });
|
||||||
target,
|
const resolvedPromotionType = resolveExternalEntryPromotionType({
|
||||||
redirect,
|
mode,
|
||||||
next,
|
promotionType,
|
||||||
});
|
});
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await persistExternalEntryPayload({
|
await Promise.all([
|
||||||
deviceId,
|
persistExternalEntryPayload({
|
||||||
asid,
|
deviceId,
|
||||||
psid,
|
asid,
|
||||||
avatarUrl,
|
psid,
|
||||||
});
|
avatarUrl,
|
||||||
|
}),
|
||||||
|
destination === ROUTES.chat && resolvedPromotionType
|
||||||
|
? savePendingChatPromotion(resolvedPromotionType)
|
||||||
|
: clearPendingChatPromotion(),
|
||||||
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn("[ExternalEntryPersist] failed to persist payload", error);
|
log.warn("[ExternalEntryPersist] failed to persist payload", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -57,9 +68,9 @@ export default function ExternalEntryPersist({
|
|||||||
avatarUrl,
|
avatarUrl,
|
||||||
asid,
|
asid,
|
||||||
deviceId,
|
deviceId,
|
||||||
next,
|
mode,
|
||||||
|
promotionType,
|
||||||
psid,
|
psid,
|
||||||
redirect,
|
|
||||||
router,
|
router,
|
||||||
target,
|
target,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*
|
*
|
||||||
* 外部应用可以通过 query 传入 Facebook 相关信息和最终目标页,例如:
|
* 外部应用可以通过 query 传入 Facebook 相关信息和最终目标页,例如:
|
||||||
* `/external-entry?target=chat&asid=xxx&psid=yyy`
|
* `/external-entry?target=chat&asid=xxx&psid=yyy`
|
||||||
|
* `/external-entry?target=chat&mode=promotion&promotion_type=voice`
|
||||||
*
|
*
|
||||||
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
|
* 页面不直接 `redirect()`,而是把数据交给 Client 组件先写入本地存储,
|
||||||
* 再通过 `router.replace()` 清理 URL 并跳转到最终页面。
|
* 再通过 `router.replace()` 清理 URL 并跳转到最终页面。
|
||||||
@@ -23,27 +24,20 @@ export default async function ExternalEntryPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ExternalEntryPersist
|
<ExternalEntryPersist
|
||||||
deviceId={pickFirst(params.deviceId, params.device_id)}
|
deviceId={pickParam(params.device_id)}
|
||||||
asid={pickFirst(params.asid)}
|
asid={pickParam(params.asid)}
|
||||||
psid={pickFirst(params.psid)}
|
psid={pickParam(params.psid)}
|
||||||
avatarUrl={pickFirst(params.avatarUrl, params.avatar_url)}
|
avatarUrl={pickParam(params.avatar_url)}
|
||||||
target={pickFirst(params.target)}
|
target={pickParam(params.target)}
|
||||||
redirect={pickFirst(params.redirect)}
|
mode={pickParam(params.mode)}
|
||||||
next={pickFirst(params.next)}
|
promotionType={pickParam(params.promotion_type)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickFirst(
|
function pickParam(value: string | string[] | undefined): string | null {
|
||||||
...values: Array<string | string[] | undefined>
|
if (Array.isArray(value)) {
|
||||||
): string | null {
|
return value.find((item) => item.trim().length > 0) ?? null;
|
||||||
for (const value of values) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
const first = value.find((item) => item.trim().length > 0);
|
|
||||||
if (first) return first;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
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);
|
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", () => {
|
it("parses an insufficient-balance unlock response", () => {
|
||||||
const response = UnlockPrivateResponse.from({
|
const response = UnlockPrivateResponse.from({
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
} from "@/data/schemas/chat/unlock_private_request";
|
} from "@/data/schemas/chat/unlock_private_request";
|
||||||
|
|
||||||
export class UnlockPrivateRequest {
|
export class UnlockPrivateRequest {
|
||||||
declare readonly messageId: string;
|
declare readonly messageId?: string;
|
||||||
|
declare readonly lockType?: UnlockPrivateRequestData["lockType"];
|
||||||
|
declare readonly clientLockId?: string;
|
||||||
|
|
||||||
private constructor(input: UnlockPrivateRequestInput) {
|
private constructor(input: UnlockPrivateRequestInput) {
|
||||||
const data = UnlockPrivateRequestSchema.parse(input);
|
const data = UnlockPrivateRequestSchema.parse(input);
|
||||||
|
|||||||
@@ -5,14 +5,19 @@ import {
|
|||||||
type UnlockPrivateResponseInput,
|
type UnlockPrivateResponseInput,
|
||||||
} from "@/data/schemas/chat/unlock_private_response";
|
} from "@/data/schemas/chat/unlock_private_response";
|
||||||
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
||||||
|
import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单条历史付费 / 私密消息解锁响应 DTO。
|
* 单条历史付费 / 私密消息解锁响应 DTO。
|
||||||
*/
|
*/
|
||||||
export class UnlockPrivateResponse {
|
export class UnlockPrivateResponse {
|
||||||
declare readonly unlocked: boolean;
|
declare readonly unlocked: boolean;
|
||||||
|
declare readonly messageId: string;
|
||||||
|
declare readonly clientLockId: string | null;
|
||||||
|
declare readonly lockType: ChatLockType | null;
|
||||||
declare readonly content: string;
|
declare readonly content: string;
|
||||||
declare readonly audioUrl: string;
|
declare readonly audioUrl: string;
|
||||||
|
declare readonly image: ChatImageData;
|
||||||
declare readonly reason: UnlockPrivateReason;
|
declare readonly reason: UnlockPrivateReason;
|
||||||
declare readonly creditBalance: number;
|
declare readonly creditBalance: number;
|
||||||
declare readonly creditsCharged: number;
|
declare readonly creditsCharged: number;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export class ChatLocalMessageStore {
|
|||||||
...message.toJson(),
|
...message.toJson(),
|
||||||
content: normalizeUnlockedContent(patch.content, message.content),
|
content: normalizeUnlockedContent(patch.content, message.content),
|
||||||
audioUrl: normalizeUnlockedAudioUrl(patch.audioUrl, message.audioUrl),
|
audioUrl: normalizeUnlockedAudioUrl(patch.audioUrl, message.audioUrl),
|
||||||
|
image: patch.image ?? message.image,
|
||||||
lockDetail: patch.lockDetail ?? {
|
lockDetail: patch.lockDetail ?? {
|
||||||
...message.lockDetail,
|
...message.lockDetail,
|
||||||
locked: false,
|
locked: false,
|
||||||
@@ -132,6 +133,8 @@ function shouldMarkMessageUnlocked(
|
|||||||
if (message.lockDetail.locked !== true) return false;
|
if (message.lockDetail.locked !== true) return false;
|
||||||
return (
|
return (
|
||||||
Boolean(message.image.url) ||
|
Boolean(message.image.url) ||
|
||||||
|
message.lockDetail.reason === "image_paywall" ||
|
||||||
|
message.lockDetail.reason === "image" ||
|
||||||
message.lockDetail.reason === "private_message" ||
|
message.lockDetail.reason === "private_message" ||
|
||||||
message.lockDetail.reason === "voice_message"
|
message.lockDetail.reason === "voice_message"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
UnlockPrivateResponse,
|
UnlockPrivateResponse,
|
||||||
} from "@/data/dto/chat";
|
} from "@/data/dto/chat";
|
||||||
import type { ChatApi } from "@/data/services/api";
|
import type { ChatApi } from "@/data/services/api";
|
||||||
|
import type { UnlockPrivateMessageInput } from "@/data/repositories/interfaces";
|
||||||
import { Result } from "@/utils";
|
import { Result } from "@/utils";
|
||||||
|
|
||||||
export class ChatRemoteDataSource {
|
export class ChatRemoteDataSource {
|
||||||
@@ -36,10 +37,10 @@ export class ChatRemoteDataSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async unlockPrivateMessage(
|
async unlockPrivateMessage(
|
||||||
messageId: string,
|
input: UnlockPrivateMessageInput,
|
||||||
): Promise<Result<UnlockPrivateResponse>> {
|
): Promise<Result<UnlockPrivateResponse>> {
|
||||||
return Result.wrap(() =>
|
return Result.wrap(() =>
|
||||||
this.api.unlockPrivateMessage(UnlockPrivateRequest.from({ messageId })),
|
this.api.unlockPrivateMessage(UnlockPrivateRequest.from(input)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
CacheRemoteChatMediaInput,
|
CacheRemoteChatMediaInput,
|
||||||
ChatMediaLookupInput,
|
ChatMediaLookupInput,
|
||||||
IChatRepository,
|
IChatRepository,
|
||||||
|
UnlockPrivateMessageInput,
|
||||||
UnlockedPrivateMessageLocalPatch,
|
UnlockedPrivateMessageLocalPatch,
|
||||||
} from "@/data/repositories/interfaces";
|
} from "@/data/repositories/interfaces";
|
||||||
import type { Result } from "@/utils";
|
import type { Result } from "@/utils";
|
||||||
@@ -53,9 +54,9 @@ export class ChatRepository implements IChatRepository {
|
|||||||
|
|
||||||
/** 解锁单条历史付费 / 私密消息。 */
|
/** 解锁单条历史付费 / 私密消息。 */
|
||||||
async unlockPrivateMessage(
|
async unlockPrivateMessage(
|
||||||
messageId: string,
|
input: UnlockPrivateMessageInput,
|
||||||
): Promise<Result<UnlockPrivateResponse>> {
|
): Promise<Result<UnlockPrivateResponse>> {
|
||||||
return this.remote.unlockPrivateMessage(messageId);
|
return this.remote.unlockPrivateMessage(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一键解锁历史锁定消息。 */
|
/** 一键解锁历史锁定消息。 */
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
UnlockPrivateResponse,
|
UnlockPrivateResponse,
|
||||||
} from "@/data/dto/chat";
|
} from "@/data/dto/chat";
|
||||||
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
import type { ChatLockDetailData } from "@/data/schemas/chat";
|
||||||
|
import type { ChatImageData, ChatLockType } from "@/data/schemas/chat";
|
||||||
import type { LocalChatMediaRow } from "@/data/storage/chat";
|
import type { LocalChatMediaRow } from "@/data/storage/chat";
|
||||||
|
|
||||||
export interface ChatMediaLookupInput {
|
export interface ChatMediaLookupInput {
|
||||||
@@ -35,9 +36,16 @@ export interface CacheRemoteChatMediaInput extends ChatMediaLookupInput {
|
|||||||
export interface UnlockedPrivateMessageLocalPatch {
|
export interface UnlockedPrivateMessageLocalPatch {
|
||||||
lockDetail?: ChatLockDetailData;
|
lockDetail?: ChatLockDetailData;
|
||||||
audioUrl?: string | null;
|
audioUrl?: string | null;
|
||||||
|
image?: ChatImageData;
|
||||||
content?: string;
|
content?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UnlockPrivateMessageInput {
|
||||||
|
messageId?: string;
|
||||||
|
lockType?: ChatLockType;
|
||||||
|
clientLockId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IChatRepository {
|
export interface IChatRepository {
|
||||||
/** 发送一条消息。 */
|
/** 发送一条消息。 */
|
||||||
sendMessage(
|
sendMessage(
|
||||||
@@ -49,7 +57,9 @@ export interface IChatRepository {
|
|||||||
getHistory(limit?: number, offset?: number): Promise<Result<ChatHistoryResponse>>;
|
getHistory(limit?: number, offset?: number): Promise<Result<ChatHistoryResponse>>;
|
||||||
|
|
||||||
/** 解锁单条历史付费 / 私密消息。 */
|
/** 解锁单条历史付费 / 私密消息。 */
|
||||||
unlockPrivateMessage(messageId: string): Promise<Result<UnlockPrivateResponse>>;
|
unlockPrivateMessage(
|
||||||
|
input: UnlockPrivateMessageInput,
|
||||||
|
): Promise<Result<UnlockPrivateResponse>>;
|
||||||
|
|
||||||
/** 一键解锁历史锁定消息。 */
|
/** 一键解锁历史锁定消息。 */
|
||||||
unlockHistory(): Promise<Result<UnlockHistoryResponse>>;
|
unlockHistory(): Promise<Result<UnlockHistoryResponse>>;
|
||||||
|
|||||||
@@ -3,9 +3,23 @@
|
|||||||
*/
|
*/
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const UnlockPrivateRequestSchema = z.object({
|
export const ChatLockTypeSchema = z.enum([
|
||||||
messageId: z.string(),
|
"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<
|
export type UnlockPrivateRequestInput = z.input<
|
||||||
typeof UnlockPrivateRequestSchema
|
typeof UnlockPrivateRequestSchema
|
||||||
|
|||||||
@@ -1,24 +1,44 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { booleanOrFalse, numberOrZero, stringOrEmpty } from "../nullable-defaults";
|
import {
|
||||||
import { ChatLockDetailSchema } from "./chat_payloads";
|
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 UnlockPrivateReasonSchema = stringOrEmpty;
|
||||||
|
|
||||||
export const UnlockPrivateResponseSchema = z.object({
|
export const UnlockPrivateResponseSchema = z
|
||||||
unlocked: booleanOrFalse,
|
.object({
|
||||||
content: stringOrEmpty,
|
unlocked: booleanOrFalse,
|
||||||
audioUrl: stringOrEmpty,
|
content: stringOrEmpty,
|
||||||
reason: UnlockPrivateReasonSchema,
|
reply: stringOrEmpty,
|
||||||
creditBalance: numberOrZero,
|
messageId: stringOrEmpty,
|
||||||
creditsCharged: numberOrZero,
|
message_id: stringOrEmpty,
|
||||||
requiredCredits: numberOrZero,
|
clientLockId: stringOrNull,
|
||||||
shortfallCredits: numberOrZero,
|
lockType: ChatLockTypeSchema.nullable().default(null),
|
||||||
lockDetail: ChatLockDetailSchema,
|
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 UnlockPrivateReason = z.output<typeof UnlockPrivateReasonSchema>;
|
||||||
export type UnlockPrivateResponseInput = z.input<
|
export type UnlockPrivateResponseInput = z.input<
|
||||||
|
|||||||
@@ -52,6 +52,37 @@ describe("NavigationStorage", () => {
|
|||||||
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toBeNull();
|
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 () => {
|
it("saves and consumes pending chat image return sessions", async () => {
|
||||||
await NavigationStorage.savePendingChatImageReturn({
|
await NavigationStorage.savePendingChatImageReturn({
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
|
|||||||
@@ -3,28 +3,45 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||||
|
import { ChatLockTypeSchema } from "@/data/schemas/chat";
|
||||||
import { Result, SessionAsyncUtil } from "@/utils";
|
import { Result, SessionAsyncUtil } from "@/utils";
|
||||||
|
|
||||||
const MAX_AGE_MS = 30 * 60 * 1000;
|
const MAX_AGE_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
export type PendingChatUnlockKind = "private" | "voice" | "image";
|
export type PendingChatUnlockKind = "private" | "voice" | "image";
|
||||||
export type PendingChatUnlockStage = "auth" | "payment";
|
export type PendingChatUnlockStage = "auth" | "payment";
|
||||||
|
export type PendingChatPromotionType = "voice" | "image" | "private";
|
||||||
|
|
||||||
const PendingChatUnlockSchema = z.object({
|
export const PendingChatPromotionSchema = z.object({
|
||||||
reason: z.literal("single_message_unlock"),
|
promotionType: z.enum(["voice", "image", "private"]),
|
||||||
messageId: z.string().min(1),
|
lockType: ChatLockTypeSchema,
|
||||||
kind: z.enum(["private", "voice", "image"]),
|
clientLockId: z.string().min(1),
|
||||||
returnUrl: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.refine(
|
|
||||||
(value) => value.startsWith("/") && !value.startsWith("//"),
|
|
||||||
"returnUrl must be an internal route",
|
|
||||||
),
|
|
||||||
stage: z.enum(["auth", "payment"]),
|
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const PendingChatUnlockSchema = z
|
||||||
|
.object({
|
||||||
|
reason: z.literal("single_message_unlock"),
|
||||||
|
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)
|
||||||
|
.refine(
|
||||||
|
(value) => value.startsWith("/") && !value.startsWith("//"),
|
||||||
|
"returnUrl must be an internal route",
|
||||||
|
),
|
||||||
|
stage: z.enum(["auth", "payment"]),
|
||||||
|
createdAt: z.number(),
|
||||||
|
})
|
||||||
|
.refine((value) => value.messageId || value.lockType, {
|
||||||
|
message: "messageId or lockType is required",
|
||||||
|
});
|
||||||
|
|
||||||
const PendingChatImageReturnSchema = z.object({
|
const PendingChatImageReturnSchema = z.object({
|
||||||
reason: z.literal("image_paywall"),
|
reason: z.literal("image_paywall"),
|
||||||
messageId: z.string().min(1),
|
messageId: z.string().min(1),
|
||||||
@@ -39,6 +56,9 @@ const PendingChatImageReturnSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type PendingChatUnlock = z.output<typeof PendingChatUnlockSchema>;
|
export type PendingChatUnlock = z.output<typeof PendingChatUnlockSchema>;
|
||||||
|
export type PendingChatPromotion = z.output<
|
||||||
|
typeof PendingChatPromotionSchema
|
||||||
|
>;
|
||||||
export type PendingChatImageReturn = z.output<
|
export type PendingChatImageReturn = z.output<
|
||||||
typeof PendingChatImageReturnSchema
|
typeof PendingChatImageReturnSchema
|
||||||
>;
|
>;
|
||||||
@@ -54,15 +74,30 @@ export class NavigationStorage {
|
|||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
static async savePendingChatUnlock(input: {
|
static async savePendingChatUnlock(input: {
|
||||||
messageId: string;
|
displayMessageId?: string;
|
||||||
|
messageId?: string;
|
||||||
kind: PendingChatUnlockKind;
|
kind: PendingChatUnlockKind;
|
||||||
|
lockType?: PendingChatUnlock["lockType"];
|
||||||
|
clientLockId?: string;
|
||||||
|
promotion?: PendingChatPromotion;
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
stage: PendingChatUnlockStage;
|
stage: PendingChatUnlockStage;
|
||||||
}): Promise<void> {
|
}): 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 = {
|
const payload: PendingChatUnlock = {
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
messageId: input.messageId,
|
displayMessageId,
|
||||||
|
...(input.messageId ? { messageId: input.messageId } : {}),
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
|
...(input.lockType ? { lockType: input.lockType } : {}),
|
||||||
|
...(input.clientLockId ? { clientLockId: input.clientLockId } : {}),
|
||||||
|
...(input.promotion ? { promotion: input.promotion } : {}),
|
||||||
returnUrl: input.returnUrl,
|
returnUrl: input.returnUrl,
|
||||||
stage: input.stage,
|
stage: input.stage,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
@@ -105,6 +140,37 @@ export class NavigationStorage {
|
|||||||
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
|
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: {
|
static async savePendingChatImageReturn(input: {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
@@ -147,4 +213,46 @@ export class NavigationStorage {
|
|||||||
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
|
||||||
return value;
|
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",
|
chatHistory: "chat_history",
|
||||||
pendingChatImageReturn: "pending_chat_image_return",
|
pendingChatImageReturn: "pending_chat_image_return",
|
||||||
pendingChatUnlock: "pending_chat_unlock",
|
pendingChatUnlock: "pending_chat_unlock",
|
||||||
|
pendingChatPromotion: "pending_chat_promotion",
|
||||||
|
|
||||||
// pwa / app info
|
// pwa / app info
|
||||||
pwaDialogShown: "pwa_dialog_shown",
|
pwaDialogShown: "pwa_dialog_shown",
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ export async function openChatInExternalBrowser(): Promise<void> {
|
|||||||
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.externalEntry, {
|
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.externalEntry, {
|
||||||
queryParams: {
|
queryParams: {
|
||||||
target: "chat",
|
target: "chat",
|
||||||
deviceId,
|
device_id: deviceId,
|
||||||
asid,
|
asid,
|
||||||
...(psid ? { psid } : {}),
|
...(psid ? { psid } : {}),
|
||||||
...(avatarUrl ? { avatarUrl } : {}),
|
...(avatarUrl ? { avatar_url: avatarUrl } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,46 +2,60 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
|
|
||||||
import { resolveExternalEntryTarget } from "../external_entry";
|
import {
|
||||||
|
resolveExternalEntryPromotionType,
|
||||||
|
resolveExternalEntryTarget,
|
||||||
|
} from "../external_entry";
|
||||||
|
|
||||||
describe("external entry navigation", () => {
|
describe("external entry navigation", () => {
|
||||||
it("defaults to chat", () => {
|
it("defaults to chat", () => {
|
||||||
expect(resolveExternalEntryTarget({})).toBe(ROUTES.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: "chat" })).toBe(ROUTES.chat);
|
||||||
expect(resolveExternalEntryTarget({ target: "tip" })).toBe(ROUTES.tip);
|
expect(resolveExternalEntryTarget({ target: "tip" })).toBe(ROUTES.tip);
|
||||||
expect(resolveExternalEntryTarget({ target: "coffee" })).toBe(ROUTES.tip);
|
|
||||||
expect(resolveExternalEntryTarget({ target: "private-room" })).toBe(
|
expect(resolveExternalEntryTarget({ target: "private-room" })).toBe(
|
||||||
ROUTES.privateRoom,
|
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(
|
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,
|
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 () => {
|
it("peeks chat unlock return urls without clearing them", async () => {
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
returnUrl: "/chat?image=msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
@@ -85,6 +86,7 @@ describe("subscription exit helpers", () => {
|
|||||||
consumePendingChatImageReturnMock.mockResolvedValue(null);
|
consumePendingChatImageReturnMock.mockResolvedValue(null);
|
||||||
peekPendingChatUnlockMock.mockResolvedValue({
|
peekPendingChatUnlockMock.mockResolvedValue({
|
||||||
reason: "single_message_unlock",
|
reason: "single_message_unlock",
|
||||||
|
displayMessageId: "msg_1",
|
||||||
messageId: "msg_1",
|
messageId: "msg_1",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
returnUrl: "/chat?image=msg_1",
|
returnUrl: "/chat?image=msg_1",
|
||||||
|
|||||||
@@ -4,18 +4,26 @@ import {
|
|||||||
NavigationStorage,
|
NavigationStorage,
|
||||||
type PendingChatUnlock,
|
type PendingChatUnlock,
|
||||||
type PendingChatUnlockKind,
|
type PendingChatUnlockKind,
|
||||||
|
type PendingChatPromotion,
|
||||||
|
type PendingChatPromotionType,
|
||||||
type PendingChatUnlockStage,
|
type PendingChatUnlockStage,
|
||||||
} from "@/data/storage/navigation";
|
} from "@/data/storage/navigation";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
PendingChatUnlock,
|
PendingChatUnlock,
|
||||||
PendingChatUnlockKind,
|
PendingChatUnlockKind,
|
||||||
|
PendingChatPromotion,
|
||||||
|
PendingChatPromotionType,
|
||||||
PendingChatUnlockStage,
|
PendingChatUnlockStage,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function savePendingChatUnlock(input: {
|
export async function savePendingChatUnlock(input: {
|
||||||
messageId: string;
|
displayMessageId?: string;
|
||||||
|
messageId?: string;
|
||||||
kind: PendingChatUnlockKind;
|
kind: PendingChatUnlockKind;
|
||||||
|
lockType?: PendingChatUnlock["lockType"];
|
||||||
|
clientLockId?: string;
|
||||||
|
promotion?: PendingChatPromotion;
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
stage: PendingChatUnlockStage;
|
stage: PendingChatUnlockStage;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
@@ -37,3 +45,17 @@ export async function hasPendingChatUnlock(): Promise<boolean> {
|
|||||||
export async function clearPendingChatUnlock(): Promise<void> {
|
export async function clearPendingChatUnlock(): Promise<void> {
|
||||||
await NavigationStorage.clearPendingChatUnlock();
|
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 { AuthStorage } from "@/data/storage/auth/auth_storage";
|
||||||
import { UserStorage } from "@/data/storage/user/user_storage";
|
import { UserStorage } from "@/data/storage/user/user_storage";
|
||||||
|
import type { PendingChatPromotionType } from "@/data/storage/navigation";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
|
|
||||||
export type ExternalEntryTarget =
|
export type ExternalEntryTarget =
|
||||||
@@ -18,21 +19,34 @@ export interface ExternalEntryPayload {
|
|||||||
|
|
||||||
export interface ExternalEntryTargetInput {
|
export interface ExternalEntryTargetInput {
|
||||||
target?: string | null;
|
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({
|
export function resolveExternalEntryTarget({
|
||||||
target,
|
target,
|
||||||
redirect,
|
|
||||||
next,
|
|
||||||
}: ExternalEntryTargetInput): ExternalEntryTarget {
|
}: ExternalEntryTargetInput): ExternalEntryTarget {
|
||||||
return (
|
return resolveTarget(target) ?? ROUTES.chat;
|
||||||
resolveExplicitRoute(redirect) ??
|
|
||||||
resolveExplicitRoute(next) ??
|
|
||||||
resolveTargetAlias(target) ??
|
|
||||||
ROUTES.chat
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function persistExternalEntryPayload({
|
export async function persistExternalEntryPayload({
|
||||||
@@ -61,44 +75,19 @@ export async function persistExternalEntryPayload({
|
|||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveExplicitRoute(
|
function resolveTarget(
|
||||||
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(
|
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
): ExternalEntryTarget | null {
|
): ExternalEntryTarget | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const target = value.trim().toLowerCase();
|
const target = value.trim().toLowerCase();
|
||||||
|
|
||||||
if (target === "chat") return ROUTES.chat;
|
if (target === "chat") return ROUTES.chat;
|
||||||
if (target === "tip" || target === "tips") {
|
if (target === "tip") return ROUTES.tip;
|
||||||
return ROUTES.tip;
|
if (target === "private-room") return ROUTES.privateRoom;
|
||||||
}
|
|
||||||
if (target === "private-room") {
|
|
||||||
return ROUTES.privateRoom;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
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 {
|
function hasValue(value: string | null | undefined): value is string {
|
||||||
return typeof value === "string" && value.trim().length > 0;
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { PayChannel } from "@/data/dto/payment";
|
import type { PayChannel } from "@/data/dto/payment";
|
||||||
import type {
|
import type {
|
||||||
PendingChatUnlockKind,
|
PendingChatUnlockKind,
|
||||||
|
PendingChatPromotion,
|
||||||
PendingChatUnlockStage,
|
PendingChatUnlockStage,
|
||||||
} from "@/data/storage/navigation";
|
} from "@/data/storage/navigation";
|
||||||
|
|
||||||
@@ -15,16 +16,24 @@ export interface OpenSubscriptionInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StartMessageUnlockInput {
|
export interface StartMessageUnlockInput {
|
||||||
messageId: string;
|
displayMessageId: string;
|
||||||
|
messageId?: string;
|
||||||
kind: PendingChatUnlockKind;
|
kind: PendingChatUnlockKind;
|
||||||
|
lockType?: PendingChatPromotion["lockType"];
|
||||||
|
clientLockId?: string;
|
||||||
|
promotion?: PendingChatPromotion;
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
stage?: PendingChatUnlockStage;
|
stage?: PendingChatUnlockStage;
|
||||||
onAuthenticated: () => void;
|
onAuthenticated: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenSubscriptionForPendingUnlockInput {
|
export interface OpenSubscriptionForPendingUnlockInput {
|
||||||
messageId: string;
|
displayMessageId: string;
|
||||||
|
messageId?: string;
|
||||||
kind: PendingChatUnlockKind;
|
kind: PendingChatUnlockKind;
|
||||||
|
lockType?: PendingChatPromotion["lockType"];
|
||||||
|
clientLockId?: string;
|
||||||
|
promotion?: PendingChatPromotion;
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
type: AppSubscriptionType;
|
type: AppSubscriptionType;
|
||||||
payChannel?: PayChannel;
|
payChannel?: PayChannel;
|
||||||
|
|||||||
@@ -108,8 +108,12 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
|
|
||||||
const startMessageUnlock = useCallback(
|
const startMessageUnlock = useCallback(
|
||||||
({
|
({
|
||||||
|
displayMessageId,
|
||||||
messageId,
|
messageId,
|
||||||
kind,
|
kind,
|
||||||
|
lockType,
|
||||||
|
clientLockId,
|
||||||
|
promotion,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
stage = "auth",
|
stage = "auth",
|
||||||
onAuthenticated,
|
onAuthenticated,
|
||||||
@@ -121,8 +125,12 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await NavigationStorage.savePendingChatUnlock({
|
await NavigationStorage.savePendingChatUnlock({
|
||||||
|
displayMessageId,
|
||||||
messageId,
|
messageId,
|
||||||
kind,
|
kind,
|
||||||
|
lockType,
|
||||||
|
clientLockId,
|
||||||
|
promotion,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
stage,
|
stage,
|
||||||
});
|
});
|
||||||
@@ -134,16 +142,24 @@ export function useAppNavigator(): AppNavigator {
|
|||||||
|
|
||||||
const openSubscriptionForPendingUnlock = useCallback(
|
const openSubscriptionForPendingUnlock = useCallback(
|
||||||
({
|
({
|
||||||
|
displayMessageId,
|
||||||
messageId,
|
messageId,
|
||||||
kind,
|
kind,
|
||||||
|
lockType,
|
||||||
|
clientLockId,
|
||||||
|
promotion,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
type,
|
type,
|
||||||
payChannel = getDefaultPayChannel(),
|
payChannel = getDefaultPayChannel(),
|
||||||
}: OpenSubscriptionForPendingUnlockInput): void => {
|
}: OpenSubscriptionForPendingUnlockInput): void => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await NavigationStorage.savePendingChatUnlock({
|
await NavigationStorage.savePendingChatUnlock({
|
||||||
|
displayMessageId,
|
||||||
messageId,
|
messageId,
|
||||||
kind,
|
kind,
|
||||||
|
lockType,
|
||||||
|
clientLockId,
|
||||||
|
promotion,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
stage: "payment",
|
stage: "payment",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ function makeResponse(
|
|||||||
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
||||||
return {
|
return {
|
||||||
messages: [],
|
messages: [],
|
||||||
|
promotion: null,
|
||||||
isReplyingAI: true,
|
isReplyingAI: true,
|
||||||
pendingReplyCount: 1,
|
pendingReplyCount: 1,
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
@@ -56,6 +57,9 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
|||||||
isUnlockingMessage: false,
|
isUnlockingMessage: false,
|
||||||
unlockingMessageId: null,
|
unlockingMessageId: null,
|
||||||
unlockingMessageKind: null,
|
unlockingMessageKind: null,
|
||||||
|
unlockingRemoteMessageId: null,
|
||||||
|
unlockingLockType: null,
|
||||||
|
unlockingClientLockId: null,
|
||||||
unlockMessageError: null,
|
unlockMessageError: null,
|
||||||
unlockPaywallRequest: null,
|
unlockPaywallRequest: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
} from "@/data/dto/chat";
|
} from "@/data/dto/chat";
|
||||||
import { chatMachine } from "@/stores/chat/chat-machine";
|
import { chatMachine } from "@/stores/chat/chat-machine";
|
||||||
import type { ChatEvent } from "@/stores/chat/chat-events";
|
import type { ChatEvent } from "@/stores/chat/chat-events";
|
||||||
|
import type {
|
||||||
|
UnlockMessageOutput as MachineUnlockMessageOutput,
|
||||||
|
UnlockMessageRequest,
|
||||||
|
} from "@/stores/chat/chat-machine.helpers";
|
||||||
|
|
||||||
interface LoadMoreHistoryOutput {
|
interface LoadMoreHistoryOutput {
|
||||||
messages: UiMessage[];
|
messages: UiMessage[];
|
||||||
@@ -29,7 +33,7 @@ interface UnlockHistoryOutput {
|
|||||||
newOffset: number;
|
newOffset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UnlockMessageOutput {
|
interface TestUnlockMessageOutput {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
response: UnlockPrivateResponse;
|
response: UnlockPrivateResponse;
|
||||||
}
|
}
|
||||||
@@ -81,7 +85,7 @@ function createTestChatMachine(
|
|||||||
options: {
|
options: {
|
||||||
historyMessages?: UiMessage[];
|
historyMessages?: UiMessage[];
|
||||||
unlockHistoryOutput?: UnlockHistoryOutput;
|
unlockHistoryOutput?: UnlockHistoryOutput;
|
||||||
unlockMessageOutput?: UnlockMessageOutput;
|
unlockMessageOutput?: TestUnlockMessageOutput;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
return chatMachine.provide({
|
return chatMachine.provide({
|
||||||
@@ -124,13 +128,20 @@ function createTestChatMachine(
|
|||||||
newOffset: 0,
|
newOffset: 0,
|
||||||
...options.unlockHistoryOutput,
|
...options.unlockHistoryOutput,
|
||||||
})),
|
})),
|
||||||
unlockMessage: fromPromise<UnlockMessageOutput, { messageId: string }>(
|
unlockMessage: fromPromise<
|
||||||
async ({ input }) =>
|
MachineUnlockMessageOutput,
|
||||||
options.unlockMessageOutput ?? {
|
UnlockMessageRequest
|
||||||
messageId: input.messageId,
|
>(async ({ input }) => {
|
||||||
response: makeUnlockPrivateResponse(),
|
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();
|
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 () => {
|
it("renders local history before network history sync finishes", async () => {
|
||||||
let resolveNetwork!: () => void;
|
let resolveNetwork!: () => void;
|
||||||
const networkReleased = new Promise<void>((resolve) => {
|
const networkReleased = new Promise<void>((resolve) => {
|
||||||
@@ -586,7 +636,7 @@ describe("chatMachine transitions", () => {
|
|||||||
unlockMessageOutput: {
|
unlockMessageOutput: {
|
||||||
messageId: "msg-private-locked",
|
messageId: "msg-private-locked",
|
||||||
response: makeUnlockPrivateResponse({
|
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([
|
expect(actor.getSnapshot().context.messages).toMatchObject([
|
||||||
{
|
{
|
||||||
id: "msg-private-locked",
|
id: "msg-private-locked",
|
||||||
content: "Original private message content.",
|
content: "Unlocked private message content.",
|
||||||
locked: false,
|
locked: false,
|
||||||
lockReason: null,
|
lockReason: null,
|
||||||
lockedPrivate: false,
|
lockedPrivate: false,
|
||||||
@@ -647,7 +697,7 @@ describe("chatMachine transitions", () => {
|
|||||||
unlockMessageOutput: {
|
unlockMessageOutput: {
|
||||||
messageId: "msg-shared-id",
|
messageId: "msg-shared-id",
|
||||||
response: makeUnlockPrivateResponse({
|
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",
|
id: "msg-shared-id",
|
||||||
content: "Original AI private message",
|
content: "Unlocked private AI message.",
|
||||||
isFromAI: true,
|
isFromAI: true,
|
||||||
locked: false,
|
locked: false,
|
||||||
lockReason: null,
|
lockReason: null,
|
||||||
@@ -848,6 +898,7 @@ describe("chatMachine transitions", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(actor.getSnapshot().context.unlockPaywallRequest).toEqual({
|
expect(actor.getSnapshot().context.unlockPaywallRequest).toEqual({
|
||||||
|
displayMessageId: "msg-voice-locked",
|
||||||
messageId: "msg-voice-locked",
|
messageId: "msg-voice-locked",
|
||||||
kind: "voice",
|
kind: "voice",
|
||||||
reason: "insufficient_balance",
|
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 { chatMachine } from "./chat-machine";
|
||||||
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
||||||
|
import { appendPromotionMessage } from "./chat-promotion";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对外暴露的 State 形状
|
* 对外暴露的 State 形状
|
||||||
@@ -20,6 +21,8 @@ import type { ChatEvent, ChatState as MachineContext } from "./chat-machine";
|
|||||||
*/
|
*/
|
||||||
interface ChatState {
|
interface ChatState {
|
||||||
messages: MachineContext["messages"];
|
messages: MachineContext["messages"];
|
||||||
|
historyMessages: MachineContext["messages"];
|
||||||
|
promotion: MachineContext["promotion"];
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
upgradePromptVisible: boolean;
|
upgradePromptVisible: boolean;
|
||||||
upgradeReason: MachineContext["upgradeReason"];
|
upgradeReason: MachineContext["upgradeReason"];
|
||||||
@@ -51,7 +54,12 @@ export function ChatProvider({ children }: ChatProviderProps) {
|
|||||||
// 映射 XState 状态机快照 → 原 ChatState 形状
|
// 映射 XState 状态机快照 → 原 ChatState 形状
|
||||||
const chatState = useMemo<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,
|
isReplyingAI: state.context.isReplyingAI,
|
||||||
upgradePromptVisible: state.context.upgradePromptVisible,
|
upgradePromptVisible: state.context.upgradePromptVisible,
|
||||||
upgradeReason: state.context.upgradeReason,
|
upgradeReason: state.context.upgradeReason,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* chat-screen 不直接读 AuthStorage(除 token 取值)。
|
* chat-screen 不直接读 AuthStorage(除 token 取值)。
|
||||||
*/
|
*/
|
||||||
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
|
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 =
|
export type ChatEvent =
|
||||||
// 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
|
// 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
|
||||||
@@ -35,10 +37,19 @@ export type ChatEvent =
|
|||||||
| { type: "ChatSendMessage"; content: string }
|
| { type: "ChatSendMessage"; content: string }
|
||||||
| { type: "ChatSendImage"; imageBase64: string }
|
| { type: "ChatSendImage"; imageBase64: string }
|
||||||
| { type: "ChatLoadMoreHistory" }
|
| { type: "ChatLoadMoreHistory" }
|
||||||
|
| {
|
||||||
|
type: "ChatPromotionInjected";
|
||||||
|
promotion: PendingChatPromotion;
|
||||||
|
messageId?: string;
|
||||||
|
}
|
||||||
|
| { type: "ChatPromotionCleared" }
|
||||||
| {
|
| {
|
||||||
type: "ChatUnlockMessageRequested";
|
type: "ChatUnlockMessageRequested";
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
remoteMessageId?: string;
|
||||||
kind: PendingChatUnlockKind;
|
kind: PendingChatUnlockKind;
|
||||||
|
lockType?: ChatLockType;
|
||||||
|
clientLockId?: string;
|
||||||
}
|
}
|
||||||
| { type: "ChatUnlockPaywallNavigationConsumed" }
|
| { type: "ChatUnlockPaywallNavigationConsumed" }
|
||||||
| { type: "ChatPaymentSucceeded" }
|
| { type: "ChatPaymentSucceeded" }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ChatState } from "./chat-state";
|
|||||||
export const PAGE_SIZE = 50;
|
export const PAGE_SIZE = 50;
|
||||||
|
|
||||||
export * from "./chat-message-mappers";
|
export * from "./chat-message-mappers";
|
||||||
|
export * from "./chat-promotion";
|
||||||
export * from "./chat-send-state";
|
export * from "./chat-send-state";
|
||||||
export * from "./chat-unlock-helpers";
|
export * from "./chat-unlock-helpers";
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
applyNetworkHistoryLoadedOutput,
|
applyNetworkHistoryLoadedOutput,
|
||||||
countLockedHistoryMessages,
|
countLockedHistoryMessages,
|
||||||
shouldPromptUnlockHistory,
|
shouldPromptUnlockHistory,
|
||||||
|
createChatPromotionState,
|
||||||
} from "./chat-machine.helpers";
|
} from "./chat-machine.helpers";
|
||||||
import {
|
import {
|
||||||
loadHistoryActor,
|
loadHistoryActor,
|
||||||
@@ -113,18 +114,32 @@ export const chatMachine = setup({
|
|||||||
clearUnlockPaywallRequest: clearUnlockPaywallRequestAction,
|
clearUnlockPaywallRequest: clearUnlockPaywallRequestAction,
|
||||||
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
|
enqueueMessage: sendTo("messageQueue", ({ event }) => event),
|
||||||
|
|
||||||
startGuestSession: assign(() => ({
|
startGuestSession: assign(({ context }) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
|
promotion: context.promotion,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
startUserSession: assign(() => ({
|
startUserSession: assign(({ context }) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
|
promotion: context.promotion,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
clearChatSession: assign(() => ({
|
clearChatSession: assign(() => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
injectPromotion: assign(({ event }) => {
|
||||||
|
if (event.type !== "ChatPromotionInjected") return {};
|
||||||
|
return {
|
||||||
|
promotion: createChatPromotionState(
|
||||||
|
event.promotion,
|
||||||
|
event.messageId,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
clearPromotion: assign({ promotion: null }),
|
||||||
|
|
||||||
applyLocalHistoryLoaded: assign(({ event }) => {
|
applyLocalHistoryLoaded: assign(({ event }) => {
|
||||||
if (event.type !== "ChatLocalHistoryLoaded") return {};
|
if (event.type !== "ChatLocalHistoryLoaded") return {};
|
||||||
return applyHistoryLoadedOutput(event.output);
|
return applyHistoryLoadedOutput(event.output);
|
||||||
@@ -169,6 +184,10 @@ export const chatMachine = setup({
|
|||||||
id: "chat",
|
id: "chat",
|
||||||
initial: "idle",
|
initial: "idle",
|
||||||
context: initialState,
|
context: initialState,
|
||||||
|
on: {
|
||||||
|
ChatPromotionInjected: { actions: "injectPromotion" },
|
||||||
|
ChatPromotionCleared: { actions: "clearPromotion" },
|
||||||
|
},
|
||||||
states: {
|
states: {
|
||||||
idle: {
|
idle: {
|
||||||
on: {
|
on: {
|
||||||
@@ -471,7 +490,16 @@ export const chatMachine = setup({
|
|||||||
id: "unlockMessage",
|
id: "unlockMessage",
|
||||||
src: "unlockMessage",
|
src: "unlockMessage",
|
||||||
input: ({ context }) => ({
|
input: ({ context }) => ({
|
||||||
messageId: context.unlockingMessageId ?? "",
|
displayMessageId: context.unlockingMessageId ?? "",
|
||||||
|
...(context.unlockingRemoteMessageId
|
||||||
|
? { messageId: context.unlockingRemoteMessageId }
|
||||||
|
: {}),
|
||||||
|
...(context.unlockingLockType
|
||||||
|
? { lockType: context.unlockingLockType }
|
||||||
|
: {}),
|
||||||
|
...(context.unlockingClientLockId
|
||||||
|
? { clientLockId: context.unlockingClientLockId }
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
onDone: [
|
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 { UiMessage } from "@/data/dto/chat";
|
||||||
import type { PendingChatUnlockKind } from "@/lib/navigation/chat_unlock_session";
|
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 type ChatUpgradeReason = "insufficient_credits";
|
||||||
|
|
||||||
export interface ChatUnlockPaywallRequest {
|
export interface ChatUnlockPaywallRequest {
|
||||||
messageId: string;
|
displayMessageId: string;
|
||||||
|
messageId?: string;
|
||||||
kind: PendingChatUnlockKind;
|
kind: PendingChatUnlockKind;
|
||||||
|
lockType?: ChatLockType;
|
||||||
|
clientLockId?: string;
|
||||||
|
promotion?: PendingChatPromotion;
|
||||||
reason: string;
|
reason: string;
|
||||||
creditBalance: number;
|
creditBalance: number;
|
||||||
requiredCredits: number;
|
requiredCredits: number;
|
||||||
@@ -14,6 +21,7 @@ export interface ChatUnlockPaywallRequest {
|
|||||||
|
|
||||||
export interface ChatState {
|
export interface ChatState {
|
||||||
messages: UiMessage[];
|
messages: UiMessage[];
|
||||||
|
promotion: ChatPromotionState | null;
|
||||||
isReplyingAI: boolean;
|
isReplyingAI: boolean;
|
||||||
pendingReplyCount: number;
|
pendingReplyCount: number;
|
||||||
upgradePromptVisible: boolean;
|
upgradePromptVisible: boolean;
|
||||||
@@ -40,12 +48,16 @@ export interface ChatState {
|
|||||||
isUnlockingMessage: boolean;
|
isUnlockingMessage: boolean;
|
||||||
unlockingMessageId: string | null;
|
unlockingMessageId: string | null;
|
||||||
unlockingMessageKind: PendingChatUnlockKind | null;
|
unlockingMessageKind: PendingChatUnlockKind | null;
|
||||||
|
unlockingRemoteMessageId: string | null;
|
||||||
|
unlockingLockType: ChatLockType | null;
|
||||||
|
unlockingClientLockId: string | null;
|
||||||
unlockMessageError: string | null;
|
unlockMessageError: string | null;
|
||||||
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
|
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initialState: ChatState = {
|
export const initialState: ChatState = {
|
||||||
messages: [],
|
messages: [],
|
||||||
|
promotion: null,
|
||||||
isReplyingAI: false,
|
isReplyingAI: false,
|
||||||
pendingReplyCount: 0,
|
pendingReplyCount: 0,
|
||||||
upgradePromptVisible: false,
|
upgradePromptVisible: false,
|
||||||
@@ -68,6 +80,9 @@ export const initialState: ChatState = {
|
|||||||
isUnlockingMessage: false,
|
isUnlockingMessage: false,
|
||||||
unlockingMessageId: null,
|
unlockingMessageId: null,
|
||||||
unlockingMessageKind: null,
|
unlockingMessageKind: null,
|
||||||
|
unlockingRemoteMessageId: null,
|
||||||
|
unlockingLockType: null,
|
||||||
|
unlockingClientLockId: null,
|
||||||
unlockMessageError: null,
|
unlockMessageError: null,
|
||||||
unlockPaywallRequest: null,
|
unlockPaywallRequest: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
import { readAndSyncHistory } from "./chat-history-sync";
|
import { readAndSyncHistory } from "./chat-history-sync";
|
||||||
import {
|
import {
|
||||||
applySingleUnlockOutput,
|
applySingleUnlockOutput,
|
||||||
|
applyPromotionUnlockOutput,
|
||||||
countLockedHistoryMessages,
|
countLockedHistoryMessages,
|
||||||
|
type UnlockMessageRequest,
|
||||||
type UnlockMessageOutput,
|
type UnlockMessageOutput,
|
||||||
} from "./chat-machine.helpers";
|
} from "./chat-machine.helpers";
|
||||||
|
|
||||||
@@ -49,24 +51,32 @@ export const unlockHistoryActor = fromPromise<{
|
|||||||
|
|
||||||
export const unlockMessageActor = fromPromise<
|
export const unlockMessageActor = fromPromise<
|
||||||
UnlockMessageOutput,
|
UnlockMessageOutput,
|
||||||
{ messageId: string }
|
UnlockMessageRequest
|
||||||
>(async ({ input }) => {
|
>(async ({ input }) => {
|
||||||
const chatRepo = getChatRepository();
|
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)) {
|
if (Result.isErr(unlockResult)) {
|
||||||
log.error("[chat-machine] unlockMessageActor failed", {
|
log.error("[chat-machine] unlockMessageActor failed", {
|
||||||
messageId: input.messageId,
|
messageId: input.messageId,
|
||||||
|
clientLockId: input.clientLockId,
|
||||||
error: unlockResult.error,
|
error: unlockResult.error,
|
||||||
});
|
});
|
||||||
throw unlockResult.error;
|
throw unlockResult.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unlockResult.data.unlocked) {
|
if (unlockResult.data.unlocked && input.messageId && !input.clientLockId) {
|
||||||
const markResult = await chatRepo.markPrivateMessageUnlockedInLocal(
|
const markResult = await chatRepo.markPrivateMessageUnlockedInLocal(
|
||||||
input.messageId,
|
input.messageId,
|
||||||
{
|
{
|
||||||
content: unlockResult.data.content,
|
content: unlockResult.data.content,
|
||||||
audioUrl: unlockResult.data.audioUrl,
|
audioUrl: unlockResult.data.audioUrl,
|
||||||
|
...(unlockResult.data.image.url
|
||||||
|
? { image: unlockResult.data.image }
|
||||||
|
: {}),
|
||||||
lockDetail: unlockResult.data.lockDetail,
|
lockDetail: unlockResult.data.lockDetail,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -79,7 +89,8 @@ export const unlockMessageActor = fromPromise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messageId: input.messageId,
|
displayMessageId: input.displayMessageId,
|
||||||
|
request: input,
|
||||||
response: unlockResult.data,
|
response: unlockResult.data,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -124,6 +135,10 @@ export const markUnlockMessageStartedAction = chatAssign(
|
|||||||
isUnlockingMessage: true,
|
isUnlockingMessage: true,
|
||||||
unlockingMessageId: event.messageId,
|
unlockingMessageId: event.messageId,
|
||||||
unlockingMessageKind: event.kind,
|
unlockingMessageKind: event.kind,
|
||||||
|
unlockingRemoteMessageId:
|
||||||
|
event.remoteMessageId ?? (event.lockType ? null : event.messageId),
|
||||||
|
unlockingLockType: event.lockType ?? null,
|
||||||
|
unlockingClientLockId: event.clientLockId ?? null,
|
||||||
unlockMessageError: null,
|
unlockMessageError: null,
|
||||||
unlockPaywallRequest: null,
|
unlockPaywallRequest: null,
|
||||||
};
|
};
|
||||||
@@ -136,9 +151,13 @@ export const applyUnlockMessageSucceededAction = chatAssign(
|
|||||||
if (!output) return {};
|
if (!output) return {};
|
||||||
return {
|
return {
|
||||||
messages: applySingleUnlockOutput(context.messages, output),
|
messages: applySingleUnlockOutput(context.messages, output),
|
||||||
|
promotion: applyPromotionUnlockOutput(context.promotion, output),
|
||||||
isUnlockingMessage: false,
|
isUnlockingMessage: false,
|
||||||
unlockingMessageId: null,
|
unlockingMessageId: null,
|
||||||
unlockingMessageKind: null,
|
unlockingMessageKind: null,
|
||||||
|
unlockingRemoteMessageId: null,
|
||||||
|
unlockingLockType: null,
|
||||||
|
unlockingClientLockId: null,
|
||||||
unlockMessageError: null,
|
unlockMessageError: null,
|
||||||
unlockPaywallRequest: null,
|
unlockPaywallRequest: null,
|
||||||
creditBalance: output.response.creditBalance,
|
creditBalance: output.response.creditBalance,
|
||||||
@@ -154,13 +173,33 @@ export const requestUnlockPaymentFromOutputAction = chatAssign(
|
|||||||
const output = (event as { output?: UnlockMessageOutput }).output;
|
const output = (event as { output?: UnlockMessageOutput }).output;
|
||||||
if (!output) return {};
|
if (!output) return {};
|
||||||
return {
|
return {
|
||||||
|
promotion: applyPromotionUnlockOutput(context.promotion, output),
|
||||||
isUnlockingMessage: false,
|
isUnlockingMessage: false,
|
||||||
unlockingMessageId: null,
|
unlockingMessageId: null,
|
||||||
unlockingMessageKind: null,
|
unlockingMessageKind: null,
|
||||||
|
unlockingRemoteMessageId: null,
|
||||||
|
unlockingLockType: null,
|
||||||
|
unlockingClientLockId: null,
|
||||||
unlockMessageError: output.response.reason,
|
unlockMessageError: output.response.reason,
|
||||||
unlockPaywallRequest: {
|
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",
|
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,
|
reason: output.response.reason,
|
||||||
creditBalance: output.response.creditBalance,
|
creditBalance: output.response.creditBalance,
|
||||||
requiredCredits: output.response.requiredCredits,
|
requiredCredits: output.response.requiredCredits,
|
||||||
@@ -180,8 +219,20 @@ export const requestUnlockPaymentFromErrorAction = chatAssign(
|
|||||||
unlockPaywallRequest:
|
unlockPaywallRequest:
|
||||||
context.unlockingMessageId && context.unlockingMessageKind
|
context.unlockingMessageId && context.unlockingMessageKind
|
||||||
? {
|
? {
|
||||||
messageId: context.unlockingMessageId,
|
displayMessageId: context.unlockingMessageId,
|
||||||
|
...(context.unlockingRemoteMessageId
|
||||||
|
? { messageId: context.unlockingRemoteMessageId }
|
||||||
|
: {}),
|
||||||
kind: context.unlockingMessageKind,
|
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",
|
reason: "unlock_failed",
|
||||||
creditBalance: context.creditBalance,
|
creditBalance: context.creditBalance,
|
||||||
requiredCredits: context.requiredCredits,
|
requiredCredits: context.requiredCredits,
|
||||||
@@ -190,6 +241,9 @@ export const requestUnlockPaymentFromErrorAction = chatAssign(
|
|||||||
: null,
|
: null,
|
||||||
unlockingMessageId: null,
|
unlockingMessageId: null,
|
||||||
unlockingMessageKind: null,
|
unlockingMessageKind: null,
|
||||||
|
unlockingRemoteMessageId: null,
|
||||||
|
unlockingLockType: null,
|
||||||
|
unlockingClientLockId: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import type { UiMessage, UnlockPrivateResponse } from "@/data/dto/chat";
|
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 {
|
export interface UnlockMessageOutput {
|
||||||
messageId: string;
|
displayMessageId: string;
|
||||||
|
request: UnlockMessageRequest;
|
||||||
response: UnlockPrivateResponse;
|
response: UnlockPrivateResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,17 +22,35 @@ export function applySingleUnlockOutput(
|
|||||||
messages: readonly UiMessage[],
|
messages: readonly UiMessage[],
|
||||||
output: UnlockMessageOutput,
|
output: UnlockMessageOutput,
|
||||||
): UiMessage[] {
|
): UiMessage[] {
|
||||||
if (!output.response.unlocked) return [...messages];
|
|
||||||
|
|
||||||
return messages.map((message) => {
|
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 {
|
return {
|
||||||
...message,
|
...message,
|
||||||
|
id: resolvedId,
|
||||||
|
content: resolvedContent,
|
||||||
audioUrl: getUnlockedAudioUrl(message, output.response),
|
audioUrl: getUnlockedAudioUrl(message, output.response),
|
||||||
|
imageUrl: resolvedImageUrl,
|
||||||
locked: output.response.lockDetail.locked,
|
locked: output.response.lockDetail.locked,
|
||||||
lockReason: output.response.lockDetail.reason,
|
lockReason: output.response.lockDetail.reason,
|
||||||
imagePaywalled: message.imageUrl
|
imagePaywalled: resolvedImageUrl
|
||||||
? output.response.lockDetail.locked &&
|
? output.response.lockDetail.locked &&
|
||||||
output.response.lockDetail.showUpgrade
|
output.response.lockDetail.showUpgrade
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -48,6 +75,8 @@ function shouldApplySingleUnlock(message: UiMessage, messageId: string): boolean
|
|||||||
if (message.locked !== true) return false;
|
if (message.locked !== true) return false;
|
||||||
return (
|
return (
|
||||||
message.imagePaywalled === true ||
|
message.imagePaywalled === true ||
|
||||||
|
message.lockReason === "image_paywall" ||
|
||||||
|
message.lockReason === "image" ||
|
||||||
message.lockReason === "private_message" ||
|
message.lockReason === "private_message" ||
|
||||||
message.lockReason === "voice_message"
|
message.lockReason === "voice_message"
|
||||||
);
|
);
|
||||||
@@ -61,6 +90,8 @@ function isUnlockableLockedMessage(message: UiMessage): boolean {
|
|||||||
if (!message.isFromAI || message.locked !== true) return false;
|
if (!message.isFromAI || message.locked !== true) return false;
|
||||||
if (message.imagePaywalled === true) return true;
|
if (message.imagePaywalled === true) return true;
|
||||||
return (
|
return (
|
||||||
|
message.lockReason === "image_paywall" ||
|
||||||
|
message.lockReason === "image" ||
|
||||||
message.lockReason === "private_message" ||
|
message.lockReason === "private_message" ||
|
||||||
message.lockReason === "voice_message"
|
message.lockReason === "voice_message"
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user