feat(chat): add promotional external entry flow

This commit is contained in:
2026-07-13 12:43:18 +08:00
parent e5ee9940ca
commit 3752b3b729
44 changed files with 1623 additions and 190 deletions
@@ -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 恢复锁定或解锁状态
```
+62
View File
@@ -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` 固定回退到聊天页。
+62 -5
View File
@@ -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=""');
});
});
+5
View File
@@ -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,18 +10,21 @@ 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 (
<> <>
{includeHistoryUnlock ? (
<HistoryUnlockDialog <HistoryUnlockDialog
open={chatState.unlockHistoryPromptVisible} open={chatState.unlockHistoryPromptVisible}
lockedCount={chatState.lockedHistoryCount} lockedCount={chatState.lockedHistoryCount}
@@ -30,6 +33,7 @@ export function ChatUnlockDialogs({
onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })} onClose={() => chatDispatch({ type: "ChatUnlockHistoryDismissed" })}
onConfirm={() => chatDispatch({ type: "ChatUnlockHistoryConfirmed" })} 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([
persistExternalEntryPayload({
deviceId, deviceId,
asid, asid,
psid, psid,
avatarUrl, 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,
]); ]);
+11 -17
View File
@@ -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>
): string | null {
for (const value of values) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
const first = value.find((item) => item.trim().length > 0); return value.find((item) => item.trim().length > 0) ?? null;
if (first) return first;
continue;
} }
if (value && value.trim().length > 0) return value; return value && value.trim().length > 0 ? value : null;
}
return 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)),
); );
} }
+3 -2
View File
@@ -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
.object({
unlocked: booleanOrFalse, unlocked: booleanOrFalse,
content: stringOrEmpty, content: stringOrEmpty,
reply: stringOrEmpty,
messageId: stringOrEmpty,
message_id: stringOrEmpty,
clientLockId: stringOrNull,
lockType: ChatLockTypeSchema.nullable().default(null),
audioUrl: stringOrEmpty, audioUrl: stringOrEmpty,
audio_url: stringOrEmpty,
image: ChatImageSchema,
reason: UnlockPrivateReasonSchema, reason: UnlockPrivateReasonSchema,
creditBalance: numberOrZero, creditBalance: numberOrZero,
creditsCharged: numberOrZero, creditsCharged: numberOrZero,
requiredCredits: numberOrZero, requiredCredits: numberOrZero,
shortfallCredits: numberOrZero, shortfallCredits: numberOrZero,
lockDetail: ChatLockDetailSchema, 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,17 +3,31 @@
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({
promotionType: z.enum(["voice", "image", "private"]),
lockType: ChatLockTypeSchema,
clientLockId: z.string().min(1),
createdAt: z.number(),
});
const PendingChatUnlockSchema = z
.object({
reason: z.literal("single_message_unlock"), reason: z.literal("single_message_unlock"),
messageId: z.string().min(1), displayMessageId: z.string().min(1),
messageId: z.string().min(1).optional(),
kind: z.enum(["private", "voice", "image"]), kind: z.enum(["private", "voice", "image"]),
lockType: ChatLockTypeSchema.optional(),
clientLockId: z.string().min(1).optional(),
promotion: PendingChatPromotionSchema.optional(),
returnUrl: z returnUrl: z
.string() .string()
.min(1) .min(1)
@@ -23,7 +37,10 @@ const PendingChatUnlockSchema = z.object({
), ),
stage: z.enum(["auth", "payment"]), stage: z.enum(["auth", "payment"]),
createdAt: z.number(), 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"),
@@ -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("-");
} }
+1
View File
@@ -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",
+2 -2
View File
@@ -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",
+23 -1
View File
@@ -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();
}
+27 -38
View File
@@ -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;
} }
+11 -2
View File
@@ -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;
+16
View File
@@ -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 }) => {
const output = options.unlockMessageOutput ?? {
messageId: input.messageId ?? input.displayMessageId,
response: makeUnlockPrivateResponse(), 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]);
});
});
+9 -1
View File
@@ -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,
+11
View File
@@ -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" }
+1
View File
@@ -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";
+31 -3
View File
@@ -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: [
{ {
+68
View File
@@ -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,
};
}
+16 -1
View File
@@ -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,
}; };
+60 -6
View File
@@ -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,
}), }),
); );
+36 -5
View File
@@ -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"
); );