Compare commits
8 Commits
56ee6f7b96
...
a317a46e87
| Author | SHA1 | Date | |
|---|---|---|---|
| a317a46e87 | |||
| e813333607 | |||
| b531b02a85 | |||
| 8bd67b83c5 | |||
| 91e4a074c8 | |||
| adfb230908 | |||
| 291f268cce | |||
| 06af88dc44 |
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- test
|
- pre
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
DEPLOY_ENV="prod"
|
DEPLOY_ENV="prod"
|
||||||
NEXT_ENV_FILE=".env.production"
|
NEXT_ENV_FILE=".env.production"
|
||||||
;;
|
;;
|
||||||
test)
|
pre)
|
||||||
DEPLOY_ENV="test"
|
DEPLOY_ENV="test"
|
||||||
NEXT_ENV_FILE=".env.local"
|
NEXT_ENV_FILE=".env.local"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -72,12 +72,12 @@ copy_env_by_branch() {
|
|||||||
START_PORT=9185
|
START_PORT=9185
|
||||||
echo "=== env: copied .env.production (main → prod) ===" >> "$LOG_FILE"
|
echo "=== env: copied .env.production (main → prod) ===" >> "$LOG_FILE"
|
||||||
;;
|
;;
|
||||||
test)
|
pre)
|
||||||
cp -f env-example/.env.local.example .env.local
|
cp -f env-example/.env.local.example .env.local
|
||||||
ENV_FILE=".env.local"
|
ENV_FILE=".env.local"
|
||||||
DEPLOY_ENV="test"
|
DEPLOY_ENV="test"
|
||||||
START_PORT=9135
|
START_PORT=9135
|
||||||
echo "=== env: copied .env.local (test → test) ===" >> "$LOG_FILE"
|
echo "=== env: copied .env.local (pre branch → test runtime) ===" >> "$LOG_FILE"
|
||||||
;;
|
;;
|
||||||
dev)
|
dev)
|
||||||
cp -f env-example/.env.development.example .env.local
|
cp -f env-example/.env.development.example .env.local
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
| 环境 | Git remote | 推送分支 | 宿主机端口 | 容器端口 | 部署脚本 |
|
| 环境 | Git remote | 推送分支 | 宿主机端口 | 容器端口 | 部署脚本 |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 测试环境 | `test` | `test` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` |
|
| 预发环境 | `test` | `pre` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` |
|
||||||
| 生产环境 | `production` | `main` | `9185` | `3000` | `scripts/deploy/deploy_web.sh` |
|
| 生产环境 | `production` | `main` | `9185` | `3000` | `scripts/deploy/deploy_web.sh` |
|
||||||
|
|
||||||
当前 remote 配置示例:
|
当前 remote 配置示例:
|
||||||
@@ -26,15 +26,15 @@ production root@43.106.13.130:/root/cozsweet-repos/main
|
|||||||
|
|
||||||
## 本地发布流程
|
## 本地发布流程
|
||||||
|
|
||||||
旧测试环境发布流程(脚本迁移前):
|
旧预发环境发布流程(脚本迁移前):
|
||||||
|
|
||||||
1. 进入 test worktree:`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test`
|
1. 进入 pre worktree:`cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre`
|
||||||
2. 确认当前分支。
|
2. 确认当前分支。
|
||||||
3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `test` 分支。
|
3. 执行 `git rebase dev`,把 `dev` 的最新代码变基到 `pre` 分支。
|
||||||
4. 复制测试环境图标到 `public/`。
|
4. 复制测试环境图标到 `public/`。
|
||||||
5. 复制 `env-example/.env.local.example` 到 `.env.local`。
|
5. 复制 `env-example/.env.local.example` 到 `.env.local`。
|
||||||
6. 执行旧版 `scripts/deploy/deploy_web_test.sh`。
|
6. 执行旧版 `scripts/deploy/deploy_web_test.sh`。
|
||||||
7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `test` 分支。
|
7. 旧版 `deploy_web_test.sh` 推送 `test` remote 的 `pre` 分支。
|
||||||
8. 推送成功后尝试清除 Cloudflare CDN 缓存。
|
8. 推送成功后尝试清除 Cloudflare CDN 缓存。
|
||||||
|
|
||||||
旧生产环境发布流程(脚本迁移前):
|
旧生产环境发布流程(脚本迁移前):
|
||||||
@@ -71,7 +71,7 @@ production root@43.106.13.130:/root/cozsweet-repos/main
|
|||||||
| 服务端分支 | 环境变量复制 | 宿主机端口 | 容器名 | 镜像标签 |
|
| 服务端分支 | 环境变量复制 | 宿主机端口 | 容器名 | 镜像标签 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `main` | `env-example/.env.production.example` → `.env.production` | `9185` | `cozsweet-web-prod` | `cozsweet-web:prod-<commit>` |
|
| `main` | `env-example/.env.production.example` → `.env.production` | `9185` | `cozsweet-web-prod` | `cozsweet-web:prod-<commit>` |
|
||||||
| `test` | `env-example/.env.local.example` → `.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` |
|
| `pre` | `env-example/.env.local.example` → `.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` |
|
||||||
| `dev` | `env-example/.env.development.example` → `.env.local` | `9135` | `cozsweet-web-dev` | `cozsweet-web:dev-<commit>` |
|
| `dev` | `env-example/.env.development.example` → `.env.local` | `9135` | `cozsweet-web-dev` | `cozsweet-web:dev-<commit>` |
|
||||||
| 其他分支 | 使用默认 `.env.local` | `9135` | `cozsweet-web-<branch>` | `cozsweet-web:<branch>-<commit>` |
|
| 其他分支 | 使用默认 `.env.local` | `9135` | `cozsweet-web-<branch>` | `cozsweet-web:<branch>-<commit>` |
|
||||||
|
|
||||||
@@ -264,12 +264,12 @@ ABORTED ... (docker compose up failed)
|
|||||||
|
|
||||||
1. `scripts/deploy/*` 只负责推送代码,不负责本地构建和本地启动。
|
1. `scripts/deploy/*` 只负责推送代码,不负责本地构建和本地启动。
|
||||||
2. 自动构建和自动重启只发生在服务器端 `post-receive` hook。
|
2. 自动构建和自动重启只发生在服务器端 `post-receive` hook。
|
||||||
3. `main` 分支对应生产环境,`test` 分支对应测试环境。
|
3. `main` 分支对应生产环境,`pre` 分支对应预发环境。
|
||||||
4. 生产推送当前使用 `git push --force production main`。
|
4. 生产推送当前使用 `git push --force production main`。
|
||||||
5. 构建失败时不会重启容器,旧容器继续运行。
|
5. 构建失败时不会重启容器,旧容器继续运行。
|
||||||
6. 服务启动由 Docker Compose 管理,不再使用 `nohup pnpm run start`。
|
6. 服务启动由 Docker Compose 管理,不再使用 `nohup pnpm run start`。
|
||||||
7. 服务器需要安装 Docker,并支持 `docker compose` 或旧版 `docker-compose` 命令。
|
7. 服务器需要安装 Docker,并支持 `docker compose` 或旧版 `docker-compose` 命令。
|
||||||
8. 测试环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。
|
8. 预发环境和生产环境都通过同一套 `post-receive` 容器化流程部署,差异仅由分支、环境变量文件和端口决定。
|
||||||
|
|
||||||
## 后续优化建议
|
## 后续优化建议
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
|
|
||||||
| Workflow | 触发时机 | 职责 |
|
| Workflow | 触发时机 | 职责 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `.gitea/workflows/ci.yml` | `dev`、`main`、`test` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 |
|
| `.gitea/workflows/ci.yml` | `dev`、`main`、`pre` push / PR | 安装依赖、执行完整质量检查、Python 后端 OpenAPI 差异检查、移动端 smoke,并校验 bundle 预算 |
|
||||||
| `.gitea/workflows/docker-image.yml` | `main`、`test` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 |
|
| `.gitea/workflows/docker-image.yml` | `main`、`pre` push / 手动触发 | 构建 Docker 镜像、推送到镜像仓库,并通过 SSH 部署 |
|
||||||
|
|
||||||
这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。
|
这样可以避免所有开发分支都发布镜像,并确保生产镜像通过发布门禁。
|
||||||
|
|
||||||
`main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`test`
|
`main` 分支发布前会执行 Docker workflow 内的完整质量和 bundle 门禁。`pre`
|
||||||
分支为快速部署测试环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的
|
分支用于快速部署预发环境,会跳过完整质量检查,但仍必须通过 bundle 预算;独立的
|
||||||
`ci.yml` 会同时执行完整检查并提供测试结果。
|
`ci.yml` 会同时执行完整检查并提供测试结果。
|
||||||
|
|
||||||
## 必需 Secrets
|
## 必需 Secrets
|
||||||
@@ -39,7 +39,7 @@ Next.js 的 `NEXT_PUBLIC_*` 会在构建期固化到产物中,因此测试环
|
|||||||
|
|
||||||
## 镜像 Tag 规则
|
## 镜像 Tag 规则
|
||||||
|
|
||||||
`test` 分支发布:
|
`pre` 分支发布时继续使用现有 `test-*` 运行镜像标签:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
REGISTRY_IMAGE:test-<short_sha>
|
REGISTRY_IMAGE:test-<short_sha>
|
||||||
@@ -81,7 +81,7 @@ volumes:
|
|||||||
|
|
||||||
## 验证方式
|
## 验证方式
|
||||||
|
|
||||||
推送到 `test` 分支后,检查 Actions 日志中是否出现:
|
推送到 `pre` 分支后,检查 Actions 日志中是否出现:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Build Docker image
|
Build Docker image
|
||||||
@@ -124,4 +124,4 @@ gitea.banlv-ai.com/admin/cozsweet-web:prod-<short_sha>
|
|||||||
- `prod-*` 精确 tag 只保留最近 3 个,`prod-latest` 不计入也不删除。
|
- `prod-*` 精确 tag 只保留最近 3 个,`prod-latest` 不计入也不删除。
|
||||||
- 当前刚推送的 `IMAGE_VERSION_TAG` 永远不会被删除。
|
- 当前刚推送的 `IMAGE_VERSION_TAG` 永远不会被删除。
|
||||||
|
|
||||||
SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;测试环境清理旧 `test-*`,生产环境清理旧 `prod-*`。
|
SSH 部署成功后,服务器本机只保留当前环境正在运行的一个精确版本镜像;预发环境清理旧 `test-*`,生产环境清理旧 `prod-*`。
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
当前推荐部署链路:
|
当前推荐部署链路:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
push test/main
|
push pre/main
|
||||||
→ Gitea Actions 构建并推送 Docker 镜像
|
→ Gitea Actions 构建并推送 Docker 镜像
|
||||||
→ Actions 通过 SSH 登录目标服务器
|
→ Actions 通过 SSH 登录目标服务器
|
||||||
→ 目标服务器 docker pull 指定镜像 tag
|
→ 目标服务器 docker pull 指定镜像 tag
|
||||||
@@ -16,7 +16,7 @@ push test/main
|
|||||||
|
|
||||||
| 分支 | 环境 | 镜像 tag | 宿主机端口 | 默认服务器目录 | 容器名 |
|
| 分支 | 环境 | 镜像 tag | 宿主机端口 | 默认服务器目录 | 容器名 |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| `test` | 测试环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` |
|
| `pre` | 预发环境 | `test-<short_sha>` | `9135` | `/opt/cozsweet-web-test` | `cozsweet-web-test` |
|
||||||
| `main` | 生产环境 | `prod-<short_sha>` | `9185` | `/opt/cozsweet-web-prod` | `cozsweet-web-prod` |
|
| `main` | 生产环境 | `prod-<short_sha>` | `9185` | `/opt/cozsweet-web-prod` | `cozsweet-web-prod` |
|
||||||
|
|
||||||
## Gitea Secrets
|
## Gitea Secrets
|
||||||
@@ -75,14 +75,14 @@ prod-<short_sha> 最近 3 个
|
|||||||
|
|
||||||
`test-latest` / `prod-latest` 不计入保留数量,也不会主动删除。
|
`test-latest` / `prod-latest` 不计入保留数量,也不会主动删除。
|
||||||
|
|
||||||
部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:测试环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>`。
|
部署脚本会在 `docker compose up` 成功后清理服务器本机旧镜像,只保留当前环境正在运行的一个精确版本镜像:预发环境保留当前 `test-<short_sha>`,生产环境保留当前 `prod-<short_sha>`。
|
||||||
|
|
||||||
## 本地发布入口
|
## 本地发布入口
|
||||||
|
|
||||||
本地发布脚本现在只负责推送 Gitea 分支,触发 Actions:
|
本地发布脚本现在只负责推送 Gitea 分支,触发 Actions:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 测试环境
|
# 预发环境
|
||||||
./scripts/release/pre_release_web.sh
|
./scripts/release/pre_release_web.sh
|
||||||
|
|
||||||
# 生产环境
|
# 生产环境
|
||||||
@@ -92,16 +92,16 @@ prod-<short_sha> 最近 3 个
|
|||||||
也可以直接推送:
|
也可以直接推送:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git push gitea test
|
git push gitea pre
|
||||||
git push --force gitea main
|
git push --force gitea main
|
||||||
```
|
```
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
测试环境:
|
预发环境:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh <user>@<test-host>
|
ssh <user>@<pre-host>
|
||||||
cd /opt/cozsweet-web-test
|
cd /opt/cozsweet-web-test
|
||||||
docker compose ps
|
docker compose ps
|
||||||
curl -I http://127.0.0.1:9135/
|
curl -I http://127.0.0.1:9135/
|
||||||
|
|||||||
+3
-3
@@ -27,8 +27,8 @@ pnpm exec playwright install chromium
|
|||||||
For local development on macOS, `pnpm test:e2e:chrome` can reuse the installed Google Chrome.
|
For local development on macOS, `pnpm test:e2e:chrome` can reuse the installed Google Chrome.
|
||||||
|
|
||||||
`pnpm test:e2e:mobile-smoke` runs the critical authentication, logout, token
|
`pnpm test:e2e:mobile-smoke` runs the critical authentication, logout, token
|
||||||
refresh and paid-image unlock paths with the Pixel 7 profile. CI runs this tier
|
refresh and paid-image unlock paths with the Pixel 7 profile. CI 在所有 pull
|
||||||
on every pull request and `dev` / `test` / `main` push.
|
request 以及 `dev` / `pre` / `main` push 时运行这一层测试。
|
||||||
|
|
||||||
## Environment overrides
|
## Environment overrides
|
||||||
|
|
||||||
@@ -40,6 +40,6 @@ pnpm test:e2e:real
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
PLAYWRIGHT_BASE_URL=https://cozsweet.com \
|
PLAYWRIGHT_BASE_URL=https://cozsweet.com \
|
||||||
E2E_API_BASE_URL=https://proapi.banlv-ai.com \
|
E2E_API_BASE_URL=https://api.banlv-ai.com \
|
||||||
pnpm test:e2e:prod
|
pnpm test:e2e:prod
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export function createWeeklyLimitChatSendResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const paidImageMessageId = "msg_photo_paywall_001";
|
export const paidImageMessageId = "msg_photo_paywall_001";
|
||||||
|
export const paidImageDisplayMessageId = `server:${paidImageMessageId}:assistant`;
|
||||||
export const paidImageUrl =
|
export const paidImageUrl =
|
||||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||||
export const paidImageChatSendResponse = {
|
export const paidImageChatSendResponse = {
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ export async function dismissChatInterruptions(page: Page) {
|
|||||||
|
|
||||||
export async function signInWithEmailAndOpenChat(page: Page) {
|
export async function signInWithEmailAndOpenChat(page: Page) {
|
||||||
await seedEmailSession(page);
|
await seedEmailSession(page);
|
||||||
const historyResponsePromise = page.waitForResponse("**/api/chat/history**");
|
|
||||||
await page.goto(defaultCharacterChatPath);
|
await page.goto(defaultCharacterChatPath);
|
||||||
await historyResponsePromise;
|
|
||||||
await dismissChatInterruptions(page);
|
await dismissChatInterruptions(page);
|
||||||
await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({ timeout: 20_000 });
|
await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({ timeout: 20_000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
defaultCharacterChatUrl,
|
defaultCharacterChatUrl,
|
||||||
expectInsufficientCreditsDialog,
|
expectInsufficientCreditsDialog,
|
||||||
expectVipChatSubscriptionUrl,
|
expectVipChatSubscriptionUrl,
|
||||||
setEmailSessionStorage,
|
|
||||||
submitEmailLogin,
|
submitEmailLogin,
|
||||||
switchToEmailSignIn,
|
switchToEmailSignIn,
|
||||||
} from "@e2e/fixtures/test-helpers";
|
} from "@e2e/fixtures/test-helpers";
|
||||||
@@ -36,13 +35,10 @@ test("guest unlocks a promoted image through email login and top-up", async ({
|
|||||||
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
||||||
|
|
||||||
await switchToEmailSignIn(page);
|
await switchToEmailSignIn(page);
|
||||||
await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl });
|
|
||||||
|
|
||||||
await setEmailSessionStorage(page);
|
|
||||||
const unlockRequestPromise = page.waitForRequest(
|
const unlockRequestPromise = page.waitForRequest(
|
||||||
"**/api/chat/unlock-private",
|
"**/api/chat/unlock-private",
|
||||||
);
|
);
|
||||||
await page.reload();
|
await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl });
|
||||||
|
|
||||||
const unlockRequest = await unlockRequestPromise;
|
const unlockRequest = await unlockRequestPromise;
|
||||||
expect(unlockRequest.method()).toBe("POST");
|
expect(unlockRequest.method()).toBe("POST");
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
import { paidImageMessageId } from "@e2e/fixtures/test-data";
|
import {
|
||||||
|
paidImageDisplayMessageId,
|
||||||
|
paidImageMessageId,
|
||||||
|
} from "@e2e/fixtures/test-data";
|
||||||
import {
|
import {
|
||||||
clearBrowserState,
|
clearBrowserState,
|
||||||
completeVipPayment,
|
completeVipPayment,
|
||||||
@@ -10,13 +13,12 @@ import {
|
|||||||
enterChatFromSplash,
|
enterChatFromSplash,
|
||||||
expectInsufficientCreditsDialog,
|
expectInsufficientCreditsDialog,
|
||||||
expectVipChatSubscriptionUrl,
|
expectVipChatSubscriptionUrl,
|
||||||
setEmailSessionStorage,
|
|
||||||
submitEmailLogin,
|
submitEmailLogin,
|
||||||
switchToEmailSignIn,
|
switchToEmailSignIn,
|
||||||
} from "@e2e/fixtures/test-helpers";
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
const paidImageOverlayUrl = new RegExp(
|
const paidImageOverlayUrl = new RegExp(
|
||||||
`${defaultCharacterChatPath}\\?image=${paidImageMessageId}$`,
|
`${defaultCharacterChatPath}\\?image=${encodeURIComponent(paidImageDisplayMessageId)}$`,
|
||||||
);
|
);
|
||||||
|
|
||||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
@@ -63,19 +65,16 @@ test("guest can unlock a paid image after email login and subscription payment",
|
|||||||
|
|
||||||
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
await expect(page).toHaveURL(/\/auth\?redirect=/);
|
||||||
expect(new URL(page.url()).searchParams.get("redirect")).toBe(
|
expect(new URL(page.url()).searchParams.get("redirect")).toBe(
|
||||||
`${defaultCharacterChatPath}?image=${paidImageMessageId}`,
|
`${defaultCharacterChatPath}?image=${encodeURIComponent(paidImageDisplayMessageId)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await switchToEmailSignIn(page);
|
await switchToEmailSignIn(page);
|
||||||
await submitEmailLogin(page, {
|
|
||||||
expectedUrl: paidImageOverlayUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
await setEmailSessionStorage(page);
|
|
||||||
const unlockRequestPromise = page.waitForRequest(
|
const unlockRequestPromise = page.waitForRequest(
|
||||||
"**/api/chat/unlock-private",
|
"**/api/chat/unlock-private",
|
||||||
);
|
);
|
||||||
await page.goto(`${defaultCharacterChatPath}?image=${paidImageMessageId}`);
|
await submitEmailLogin(page, {
|
||||||
|
expectedUrl: paidImageOverlayUrl,
|
||||||
|
});
|
||||||
|
|
||||||
const unlockRequest = await unlockRequestPromise;
|
const unlockRequest = await unlockRequestPromise;
|
||||||
expect(unlockRequest.postDataJSON()).toMatchObject({
|
expect(unlockRequest.postDataJSON()).toMatchObject({
|
||||||
@@ -92,7 +91,7 @@ test("guest can unlock a paid image after email login and subscription payment",
|
|||||||
|
|
||||||
await expect(page).toHaveURL(
|
await expect(page).toHaveURL(
|
||||||
new RegExp(
|
new RegExp(
|
||||||
`${defaultCharacterChatPath}(?:\\?image=msg_photo_paywall_001)?$`,
|
`${defaultCharacterChatPath}(?:\\?image=${encodeURIComponent(paidImageDisplayMessageId)})?$`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath);
|
expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
import { mockCoreApis } from "@e2e/fixtures/api-mocks";
|
||||||
import { paidImageMessageId } from "@e2e/fixtures/test-data";
|
import {
|
||||||
|
paidImageDisplayMessageId,
|
||||||
|
paidImageMessageId,
|
||||||
|
} from "@e2e/fixtures/test-data";
|
||||||
import {
|
import {
|
||||||
clearBrowserState,
|
clearBrowserState,
|
||||||
defaultCharacterChatPath,
|
defaultCharacterChatPath,
|
||||||
@@ -12,7 +15,7 @@ import {
|
|||||||
} from "@e2e/fixtures/test-helpers";
|
} from "@e2e/fixtures/test-helpers";
|
||||||
|
|
||||||
const paidImageOverlayUrl = new RegExp(
|
const paidImageOverlayUrl = new RegExp(
|
||||||
`${defaultCharacterChatPath}\\?image=${paidImageMessageId}$`,
|
`${defaultCharacterChatPath}\\?image=${encodeURIComponent(paidImageDisplayMessageId)}$`,
|
||||||
);
|
);
|
||||||
|
|
||||||
test.beforeEach(async ({ baseURL, context, page }) => {
|
test.beforeEach(async ({ baseURL, context, page }) => {
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@
|
|||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"test:e2e:mobile-smoke": "playwright test --project=mock-mobile-chrome e2e/specs/mock/auth/email-login-from-other-options.spec.ts e2e/specs/mock/auth/logout-from-profile.spec.ts e2e/specs/mock/chat/chat-send-token-refresh-retry.spec.ts e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts",
|
"test:e2e:mobile-smoke": "playwright test --project=mock-mobile-chrome e2e/specs/mock/auth/email-login-from-other-options.spec.ts e2e/specs/mock/auth/logout-from-profile.spec.ts e2e/specs/mock/chat/chat-send-token-refresh-retry.spec.ts e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts",
|
||||||
"test:e2e:real": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://frontend-test.banlv-ai.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=real-backend",
|
"test:e2e:real": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://frontend-test.banlv-ai.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=real-backend",
|
||||||
"test:e2e:prod": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://cozsweet.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=prod-smoke",
|
"test:e2e:prod": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://cozsweet.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://api.banlv-ai.com} playwright test --project=prod-smoke",
|
||||||
"contract:check": "node scripts/contracts/check-openapi.mjs",
|
"contract:check": "node scripts/contracts/check-openapi.mjs",
|
||||||
"test:contracts": "node --test scripts/contracts/__tests__/*.test.mjs",
|
"test:contracts": "node --test scripts/contracts/__tests__/*.test.mjs",
|
||||||
"perf:bundle": "next experimental-analyze --output && node scripts/performance/report-bundle.mjs",
|
"perf:bundle": "next experimental-analyze --output && node scripts/performance/report-bundle.mjs",
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export default defineConfig({
|
|||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: Boolean(process.env.CI),
|
forbidOnly: Boolean(process.env.CI),
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
// Next.js dev + Turbopack can return incomplete module payloads when the
|
||||||
|
// local mock suite compiles many routes concurrently. Keep this tier
|
||||||
|
// deterministic locally as well as in CI.
|
||||||
|
workers: 1,
|
||||||
reporter: process.env.CI
|
reporter: process.env.CI
|
||||||
? [["list"], ["html", { open: "never" }]]
|
? [["list"], ["html", { open: "never" }]]
|
||||||
: [["list"], ["html", { open: "never" }]],
|
: [["list"], ["html", { open: "never" }]],
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 35 KiB |
@@ -3,7 +3,7 @@
|
|||||||
# 推送指定分支到指定远端。
|
# 推送指定分支到指定远端。
|
||||||
# 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。
|
# 当前发布由 Gitea Actions 构建镜像并通过 SSH 部署;本脚本只负责触发分支 push。
|
||||||
# 调用方:deploy_web.sh(production)→ push_to_remote "gitea" "main"
|
# 调用方:deploy_web.sh(production)→ push_to_remote "gitea" "main"
|
||||||
# deploy_web_test.sh(test)→ push_to_remote "gitea" "test"
|
# deploy_web_test.sh(pre)→ push_to_remote "gitea" "pre"
|
||||||
push_to_remote() {
|
push_to_remote() {
|
||||||
local remote="$1"
|
local remote="$1"
|
||||||
local branch="$2"
|
local branch="$2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 测试环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型)
|
# 预发环境 Web 部署脚本(Next.js + Gitea Actions SSH 部署模型)
|
||||||
# 原始 Dart: scripts/deploy/deploy_web_test.sh
|
# 原始 Dart: scripts/deploy/deploy_web_test.sh
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ source "$SCRIPT_DIR/_deploy_lib.sh"
|
|||||||
|
|
||||||
# 主函数
|
# 主函数
|
||||||
main() {
|
main() {
|
||||||
push_to_remote "gitea" "test"
|
push_to_remote "gitea" "pre"
|
||||||
}
|
}
|
||||||
|
|
||||||
main
|
main
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ unset_git_hook_env() {
|
|||||||
|
|
||||||
unset_git_hook_env
|
unset_git_hook_env
|
||||||
|
|
||||||
# Test 分支 worktree 路径
|
# pre 分支 worktree 路径
|
||||||
WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-test"
|
WORKTREE_PATH="/Users/chase/Documents/frontend/cozsweet-frontend-nextjs.worktrees/cozsweet-nextjs-pre"
|
||||||
echo "=== 进入 test 分支 worktree ==="
|
echo "=== 进入 pre 分支 worktree ==="
|
||||||
cd "$WORKTREE_PATH" || { echo "错误: 无法进入 $WORKTREE_PATH"; exit 1; }
|
cd "$WORKTREE_PATH" || { echo "错误: 无法进入 $WORKTREE_PATH"; exit 1; }
|
||||||
|
|
||||||
echo "=== 检查当前分支 ==="
|
echo "=== 检查当前分支 ==="
|
||||||
@@ -22,17 +22,17 @@ git branch --show-current
|
|||||||
echo "=== 变基到 dev 分支 ==="
|
echo "=== 变基到 dev 分支 ==="
|
||||||
git rebase dev
|
git rebase dev
|
||||||
|
|
||||||
# 复制测试环境图标(source: $WORKTREE_PATH/icons/test → dest: $WORKTREE_PATH/public/)
|
# 复制预发环境图标(运行资源目录仍为 icons/test)
|
||||||
echo "=== 复制测试环境图标(icons/test → public/) ==="
|
echo "=== 复制预发环境图标(icons/test → public/) ==="
|
||||||
cp -f "$WORKTREE_PATH/icons/test/favicon.ico" "$WORKTREE_PATH/public/favicon.ico"
|
cp -f "$WORKTREE_PATH/icons/test/favicon.ico" "$WORKTREE_PATH/public/favicon.ico"
|
||||||
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-192.png" "$WORKTREE_PATH/public/images/icons/Icon-192.png"
|
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-192.png" "$WORKTREE_PATH/public/images/icons/Icon-192.png"
|
||||||
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-512.png" "$WORKTREE_PATH/public/images/icons/Icon-512.png"
|
cp -f "$WORKTREE_PATH/icons/test/icons/Icon-512.png" "$WORKTREE_PATH/public/images/icons/Icon-512.png"
|
||||||
|
|
||||||
# 准备测试环境变量($WORKTREE_PATH/env-example/.env.local.example → $WORKTREE_PATH/.env.local)
|
# 准备预发环境变量(运行配置仍使用 .env.local)
|
||||||
echo "=== 准备测试环境变量(env-example/.env.local.example → .env.local) ==="
|
echo "=== 准备预发环境变量(env-example/.env.local.example → .env.local) ==="
|
||||||
cp -f "$WORKTREE_PATH/env-example/.env.local.example" "$WORKTREE_PATH/.env.local"
|
cp -f "$WORKTREE_PATH/env-example/.env.local.example" "$WORKTREE_PATH/.env.local"
|
||||||
|
|
||||||
echo "=== 执行构建部署脚本(测试环境)==="
|
echo "=== 执行构建部署脚本(预发环境)==="
|
||||||
./scripts/deploy/deploy_web_test.sh
|
./scripts/deploy/deploy_web_test.sh
|
||||||
|
|
||||||
echo "=== 预发布完成 ==="
|
echo "=== 预发布完成 ==="
|
||||||
|
|||||||
@@ -132,7 +132,11 @@ export function ChatScreen() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!imageMessageId || !state.historyLoaded || selectedImageMessage) {
|
if (
|
||||||
|
!imageMessageId ||
|
||||||
|
!state.networkHistoryLoaded ||
|
||||||
|
selectedImageMessage
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(
|
router.replace(
|
||||||
@@ -145,7 +149,7 @@ export function ChatScreen() {
|
|||||||
router,
|
router,
|
||||||
searchParams,
|
searchParams,
|
||||||
selectedImageMessage,
|
selectedImageMessage,
|
||||||
state.historyLoaded,
|
state.networkHistoryLoaded,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
dispatch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/chat/chat-context", () => ({
|
||||||
|
useChatDispatch: () => mocks.dispatch,
|
||||||
|
}));
|
||||||
|
vi.mock("@/providers/character-provider", () => ({
|
||||||
|
useActiveCharacter: () => ({ id: "maya-tan" }),
|
||||||
|
useActiveCharacterRoutes: () => ({
|
||||||
|
tip: "/characters/maya/tip",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("../../hooks/use-chat-keyboard-avoidance", () => ({
|
||||||
|
useChatKeyboardAvoidance: () => undefined,
|
||||||
|
}));
|
||||||
|
vi.mock("../../hooks/use-chat-keyboard-diagnostics", () => ({
|
||||||
|
useChatKeyboardDiagnostics: () => undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ChatInputBar } from "../chat-input-bar";
|
||||||
|
|
||||||
|
describe("ChatInputBar", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||||
|
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
mocks.dispatch.mockReset();
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the ordered action menu, blurs input, and links to Tip", () => {
|
||||||
|
renderBar();
|
||||||
|
const textarea = getTextarea();
|
||||||
|
act(() => textarea.focus());
|
||||||
|
expect(document.activeElement).toBe(textarea);
|
||||||
|
|
||||||
|
act(() => getButton("Open chat actions").click());
|
||||||
|
|
||||||
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
|
const menu = container.querySelector('[aria-label="Chat actions"]');
|
||||||
|
expect(menu).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
Array.from(menu?.children ?? []).map((item) => item.textContent?.trim()),
|
||||||
|
).toEqual(["Image", "Voice", "Tip"]);
|
||||||
|
expect(getButton("Close chat actions").getAttribute("aria-expanded")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
menu
|
||||||
|
?.querySelector<HTMLAnchorElement>('[data-analytics-key="chat.open_tip"]')
|
||||||
|
?.getAttribute("href"),
|
||||||
|
).toBe("/characters/maya/tip");
|
||||||
|
|
||||||
|
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
|
||||||
|
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects image and voice promotions without persisting UI state", () => {
|
||||||
|
renderBar();
|
||||||
|
|
||||||
|
act(() => getButton("Open chat actions").click());
|
||||||
|
act(() => getButton("Image").click());
|
||||||
|
const imageEvent = mocks.dispatch.mock.calls[0]?.[0];
|
||||||
|
expect(imageEvent).toMatchObject({
|
||||||
|
type: "ChatPromotionInjected",
|
||||||
|
promotion: {
|
||||||
|
characterId: "maya-tan",
|
||||||
|
promotionType: "image",
|
||||||
|
lockType: "image_paywall",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(imageEvent.promotion.clientLockId).toMatch(/^promotion_/);
|
||||||
|
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
|
||||||
|
|
||||||
|
act(() => getButton("Open chat actions").click());
|
||||||
|
act(() => getButton("Voice").click());
|
||||||
|
const voiceEvent = mocks.dispatch.mock.calls[1]?.[0];
|
||||||
|
expect(voiceEvent).toMatchObject({
|
||||||
|
type: "ChatPromotionInjected",
|
||||||
|
promotion: {
|
||||||
|
characterId: "maya-tan",
|
||||||
|
promotionType: "voice",
|
||||||
|
lockType: "voice_message",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(voiceEvent.promotion.clientLockId).not.toBe(
|
||||||
|
imageEvent.promotion.clientLockId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches from actions to Send for non-whitespace input", () => {
|
||||||
|
renderBar();
|
||||||
|
act(() => getButton("Open chat actions").click());
|
||||||
|
|
||||||
|
setTextareaValue(getTextarea(), "Hello Maya");
|
||||||
|
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
|
||||||
|
expect(getButton("Send message")).not.toBeNull();
|
||||||
|
|
||||||
|
act(() => getButton("Send message").click());
|
||||||
|
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||||
|
type: "ChatSendMessage",
|
||||||
|
content: "Hello Maya",
|
||||||
|
});
|
||||||
|
expect(getTextarea().value).toBe("");
|
||||||
|
expect(document.activeElement).toBe(getTextarea());
|
||||||
|
|
||||||
|
setTextareaValue(getTextarea(), " ");
|
||||||
|
expect(getButton("Open chat actions")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the menu on outside interaction and when disabled", () => {
|
||||||
|
renderBar();
|
||||||
|
act(() => getButton("Open chat actions").click());
|
||||||
|
act(() => document.body.dispatchEvent(new Event("pointerdown", { bubbles: true })));
|
||||||
|
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
|
||||||
|
|
||||||
|
act(() => getButton("Open chat actions").click());
|
||||||
|
renderBar(true);
|
||||||
|
expect(container.querySelector('[aria-label="Chat actions"]')).toBeNull();
|
||||||
|
expect(getButton("Open chat actions").disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderBar(disabled = false): void {
|
||||||
|
act(() => root.render(<ChatInputBar disabled={disabled} />));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextarea(): HTMLTextAreaElement {
|
||||||
|
const textarea = container.querySelector("textarea");
|
||||||
|
if (!textarea) throw new Error("Missing chat textarea");
|
||||||
|
return textarea;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getButton(label: string): HTMLButtonElement {
|
||||||
|
const button = Array.from(
|
||||||
|
container.querySelectorAll<HTMLButtonElement>("button"),
|
||||||
|
).find(
|
||||||
|
(item) =>
|
||||||
|
item.getAttribute("aria-label") === label ||
|
||||||
|
item.textContent?.trim() === label,
|
||||||
|
);
|
||||||
|
if (!button) throw new Error(`Missing button: ${label}`);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setTextareaValue(
|
||||||
|
textarea: HTMLTextAreaElement,
|
||||||
|
value: string,
|
||||||
|
): void {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLTextAreaElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
act(() => {
|
||||||
|
setter?.call(textarea, value);
|
||||||
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -126,7 +126,10 @@ describe("chat Tailwind components", () => {
|
|||||||
<ChatSendButton
|
<ChatSendButton
|
||||||
disabled={false}
|
disabled={false}
|
||||||
hasContent={true}
|
hasContent={true}
|
||||||
|
isMenuOpen={false}
|
||||||
|
menuId="chat-actions"
|
||||||
onClick={() => undefined}
|
onClick={() => undefined}
|
||||||
|
onMenuToggle={() => undefined}
|
||||||
onPointerDownSend={() => undefined}
|
onPointerDownSend={() => undefined}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -134,7 +137,21 @@ describe("chat Tailwind components", () => {
|
|||||||
<ChatSendButton
|
<ChatSendButton
|
||||||
disabled={false}
|
disabled={false}
|
||||||
hasContent={false}
|
hasContent={false}
|
||||||
|
isMenuOpen={false}
|
||||||
|
menuId="chat-actions"
|
||||||
onClick={() => undefined}
|
onClick={() => undefined}
|
||||||
|
onMenuToggle={() => undefined}
|
||||||
|
onPointerDownSend={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const openHtml = renderToStaticMarkup(
|
||||||
|
<ChatSendButton
|
||||||
|
disabled={false}
|
||||||
|
hasContent={false}
|
||||||
|
isMenuOpen={true}
|
||||||
|
menuId="chat-actions"
|
||||||
|
onClick={() => undefined}
|
||||||
|
onMenuToggle={() => undefined}
|
||||||
onPointerDownSend={() => undefined}
|
onPointerDownSend={() => undefined}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -142,7 +159,10 @@ describe("chat Tailwind components", () => {
|
|||||||
<ChatSendButton
|
<ChatSendButton
|
||||||
disabled={true}
|
disabled={true}
|
||||||
hasContent={true}
|
hasContent={true}
|
||||||
|
isMenuOpen={false}
|
||||||
|
menuId="chat-actions"
|
||||||
onClick={() => undefined}
|
onClick={() => undefined}
|
||||||
|
onMenuToggle={() => undefined}
|
||||||
onPointerDownSend={() => undefined}
|
onPointerDownSend={() => undefined}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -150,12 +170,17 @@ describe("chat Tailwind components", () => {
|
|||||||
expect(activeHtml).toContain('aria-label="Send message"');
|
expect(activeHtml).toContain('aria-label="Send message"');
|
||||||
expect(activeHtml).toContain('data-analytics-ignore="true"');
|
expect(activeHtml).toContain('data-analytics-ignore="true"');
|
||||||
expect(activeHtml).not.toContain("data-analytics-key");
|
expect(activeHtml).not.toContain("data-analytics-key");
|
||||||
expect(activeHtml).toContain("size-(--chat-send-button-size,40px)");
|
expect(activeHtml).toContain("size-(--chat-send-button-size,42px)");
|
||||||
expect(activeHtml).toContain(
|
expect(activeHtml).toContain(
|
||||||
"bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]",
|
"bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]",
|
||||||
);
|
);
|
||||||
expect(emptyHtml).toContain("bg-[#f8a8ce]");
|
expect(emptyHtml).toContain('aria-label="Open chat actions"');
|
||||||
expect(emptyHtml).toContain("text-[rgba(255,255,255,0.88)]");
|
expect(emptyHtml).toContain('aria-expanded="false"');
|
||||||
|
expect(emptyHtml).toContain('aria-controls="chat-actions"');
|
||||||
|
expect(emptyHtml).toContain('data-analytics-key="chat.toggle_actions"');
|
||||||
|
expect(openHtml).toContain('aria-label="Close chat actions"');
|
||||||
|
expect(openHtml).toContain('aria-expanded="true"');
|
||||||
|
expect(openHtml).toContain("bg-[#38262d]");
|
||||||
expect(disabledHtml).toContain("disabled");
|
expect(disabledHtml).toContain("disabled");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 10px);
|
||||||
|
z-index: 4;
|
||||||
|
display: grid;
|
||||||
|
width: min(100%, 348px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px;
|
||||||
|
border: 1px solid rgba(77, 48, 57, 0.09);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
box-shadow:
|
||||||
|
0 22px 54px rgba(74, 45, 55, 0.16),
|
||||||
|
0 4px 14px rgba(74, 45, 55, 0.08);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
transform-origin: right bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 76px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px 6px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #faf7f8;
|
||||||
|
color: #543d45;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
background 0.18s ease,
|
||||||
|
color 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:disabled,
|
||||||
|
.action[aria-disabled="true"] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: grid;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageIcon {
|
||||||
|
background: #fff0f4;
|
||||||
|
color: #d94f7c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiceIcon {
|
||||||
|
background: #fff4e8;
|
||||||
|
color: #d87343;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipIcon {
|
||||||
|
background: #f7f0e8;
|
||||||
|
color: #986a42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:focus-visible {
|
||||||
|
outline: 3px solid rgba(246, 87, 160, 0.28);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.action:not(:disabled):not([aria-disabled="true"]):hover {
|
||||||
|
background: #fff0f5;
|
||||||
|
color: #b83d69;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 350px) {
|
||||||
|
.menu {
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px;
|
||||||
|
border-radius: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
min-height: 70px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.menu {
|
||||||
|
animation: menuReveal 0.22s cubic-bezier(0.2, 0.8, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
animation: actionReveal 0.25s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:nth-child(2) {
|
||||||
|
animation-delay: 35ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:nth-child(3) {
|
||||||
|
animation-delay: 70ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes menuReveal {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(7px) scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes actionReveal {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ImagePlus, Mic2, Coffee } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import styles from "./chat-composer-action-menu.module.css";
|
||||||
|
|
||||||
|
export interface ChatComposerActionMenuProps {
|
||||||
|
id: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
tipHref: string;
|
||||||
|
onImage: () => void;
|
||||||
|
onVoice: () => void;
|
||||||
|
onNavigate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatComposerActionMenu({
|
||||||
|
id,
|
||||||
|
disabled = false,
|
||||||
|
tipHref,
|
||||||
|
onImage,
|
||||||
|
onVoice,
|
||||||
|
onNavigate,
|
||||||
|
}: ChatComposerActionMenuProps) {
|
||||||
|
return (
|
||||||
|
<div id={id} className={styles.menu} aria-label="Chat actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.action}
|
||||||
|
disabled={disabled}
|
||||||
|
data-analytics-key="chat.promotion_image"
|
||||||
|
onClick={onImage}
|
||||||
|
>
|
||||||
|
<span className={`${styles.icon} ${styles.imageIcon}`}>
|
||||||
|
<ImagePlus size={21} strokeWidth={2} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span>Image</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.action}
|
||||||
|
disabled={disabled}
|
||||||
|
data-analytics-key="chat.promotion_voice"
|
||||||
|
onClick={onVoice}
|
||||||
|
>
|
||||||
|
<span className={`${styles.icon} ${styles.voiceIcon}`}>
|
||||||
|
<Mic2 size={21} strokeWidth={2} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span>Voice</span>
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={tipHref}
|
||||||
|
className={styles.action}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
tabIndex={disabled ? -1 : undefined}
|
||||||
|
data-analytics-key="chat.open_tip"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (disabled) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onNavigate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={`${styles.icon} ${styles.tipIcon}`}>
|
||||||
|
<Coffee size={21} strokeWidth={2} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span>Tip</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
/* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */
|
|
||||||
|
|
||||||
/* Keyboard inset is a total bottom inset, not an addition to normal spacing. */
|
/* Keyboard inset is a total bottom inset, not an addition to normal spacing. */
|
||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 0
|
padding: clamp(7px, 1.852vw, 10px)
|
||||||
calc(var(--chat-inline-padding, 16px) + var(--app-safe-right, 0px))
|
calc(var(--chat-inline-padding, 16px) + var(--app-safe-right, 0px))
|
||||||
max(
|
max(
|
||||||
calc(var(--spacing-lg, 16px) + var(--app-safe-bottom, 0px)),
|
calc(var(--spacing-lg, 16px) + var(--app-safe-bottom, 0px)),
|
||||||
@@ -12,28 +12,43 @@
|
|||||||
)
|
)
|
||||||
calc(var(--chat-inline-padding, 16px) + var(--app-safe-left, 0px));
|
calc(var(--chat-inline-padding, 16px) + var(--app-safe-left, 0px));
|
||||||
background: transparent;
|
background: transparent;
|
||||||
transition: padding-top 0.2s ease, padding-bottom 0.2s ease,
|
transition: padding-bottom 0.2s ease;
|
||||||
background-color 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.barFocused {
|
.composer {
|
||||||
padding-top: var(--spacing-md, 12px);
|
position: relative;
|
||||||
background: #feeff2;
|
width: 100%;
|
||||||
box-shadow: 0 4px 12px var(--color-input-box-shadow, rgba(0, 0, 0, 0.1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 内层:白底 + 大圆角(Dart AppRadius.radius32)+ focused 时 accent 边框 */
|
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-sm, 8px);
|
gap: 6px;
|
||||||
padding: var(--spacing-sm, 8px);
|
padding: 6px;
|
||||||
background: #fff;
|
border: 1px solid rgba(94, 62, 73, 0.11);
|
||||||
border-radius: var(--radius-full, 999px);
|
border-radius: var(--radius-full, 999px);
|
||||||
border: 1px solid transparent;
|
background: rgba(255, 255, 255, 0.94);
|
||||||
transition: border-color 0.2s ease;
|
box-shadow:
|
||||||
|
0 12px 30px rgba(75, 48, 57, 0.1),
|
||||||
|
0 2px 8px rgba(75, 48, 57, 0.05);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease,
|
||||||
|
background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowFocused {
|
.rowFocused {
|
||||||
border-color: var(--color-accent, #f84d96);
|
border-color: rgba(246, 87, 160, 0.62);
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px rgba(246, 87, 160, 0.1),
|
||||||
|
0 14px 34px rgba(92, 52, 67, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 350px) {
|
||||||
|
.bar {
|
||||||
|
padding-right: calc(12px + var(--app-safe-right, 0px));
|
||||||
|
padding-left: calc(12px + var(--app-safe-left, 0px));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { createPendingChatPromotion } from "@/lib/navigation/chat_unlock_session";
|
||||||
|
import {
|
||||||
|
useActiveCharacter,
|
||||||
|
useActiveCharacterRoutes,
|
||||||
|
} from "@/providers/character-provider";
|
||||||
import { useChatDispatch } from "@/stores/chat/chat-context";
|
import { useChatDispatch } from "@/stores/chat/chat-context";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
|
|
||||||
import { useChatKeyboardAvoidance } from "../hooks/use-chat-keyboard-avoidance";
|
import { useChatKeyboardAvoidance } from "../hooks/use-chat-keyboard-avoidance";
|
||||||
import { useChatKeyboardDiagnostics } from "../hooks/use-chat-keyboard-diagnostics";
|
import { useChatKeyboardDiagnostics } from "../hooks/use-chat-keyboard-diagnostics";
|
||||||
|
import { ChatComposerActionMenu } from "./chat-composer-action-menu";
|
||||||
import { ChatInputTextField } from "./chat-input-text-field";
|
import { ChatInputTextField } from "./chat-input-text-field";
|
||||||
import { ChatSendButton } from "./chat-send-button";
|
import { ChatSendButton } from "./chat-send-button";
|
||||||
import styles from "./chat-input-bar.module.css";
|
import styles from "./chat-input-bar.module.css";
|
||||||
|
|
||||||
const log = new Logger("AppChatComponentsChatInputBar");
|
const log = new Logger("AppChatComponentsChatInputBar");
|
||||||
const POINTER_SEND_CLICK_DEDUPE_MS = 500;
|
const POINTER_SEND_CLICK_DEDUPE_MS = 500;
|
||||||
|
const CHAT_ACTION_MENU_ID = "chat-composer-action-menu";
|
||||||
|
|
||||||
export interface ChatInputBarProps {
|
export interface ChatInputBarProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -19,22 +26,57 @@ export interface ChatInputBarProps {
|
|||||||
|
|
||||||
export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
||||||
const dispatch = useChatDispatch();
|
const dispatch = useChatDispatch();
|
||||||
|
const character = useActiveCharacter();
|
||||||
|
const characterRoutes = useActiveCharacterRoutes();
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [isActionMenuOpen, setIsActionMenuOpen] = useState(false);
|
||||||
|
const [previousDisabled, setPreviousDisabled] = useState(disabled);
|
||||||
const barRef = useRef<HTMLDivElement>(null);
|
const barRef = useRef<HTMLDivElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const lastPointerSendAtRef = useRef(0);
|
const lastPointerSendAtRef = useRef(0);
|
||||||
|
|
||||||
const hasContent = input.trim().length > 0;
|
const hasContent = input.trim().length > 0;
|
||||||
|
|
||||||
|
if (disabled !== previousDisabled) {
|
||||||
|
setPreviousDisabled(disabled);
|
||||||
|
if (disabled && isActionMenuOpen) setIsActionMenuOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
useChatKeyboardAvoidance({
|
useChatKeyboardAvoidance({
|
||||||
active: isFocused,
|
active: isFocused,
|
||||||
containerRef: barRef,
|
containerRef: barRef,
|
||||||
});
|
});
|
||||||
useChatKeyboardDiagnostics({ containerRef: barRef });
|
useChatKeyboardDiagnostics({ containerRef: barRef });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActionMenuOpen) return;
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof Node && barRef.current?.contains(target)) return;
|
||||||
|
setIsActionMenuOpen(false);
|
||||||
|
};
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setIsActionMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isActionMenuOpen]);
|
||||||
|
|
||||||
const handleInputChange = (value: string) => {
|
const handleInputChange = (value: string) => {
|
||||||
setInput(value);
|
setInput(value);
|
||||||
|
if (value.trim().length > 0) setIsActionMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocusChange = (focused: boolean) => {
|
||||||
|
setIsFocused(focused);
|
||||||
|
if (focused) setIsActionMenuOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = (source: "click" | "keyboard" | "pointerdown") => {
|
const handleSend = (source: "click" | "keyboard" | "pointerdown") => {
|
||||||
@@ -62,6 +104,7 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
|||||||
});
|
});
|
||||||
dispatch({ type: "ChatSendMessage", content: input });
|
dispatch({ type: "ChatSendMessage", content: input });
|
||||||
setInput("");
|
setInput("");
|
||||||
|
setIsActionMenuOpen(false);
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,26 +113,58 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
|
|||||||
handleSend("pointerdown");
|
handleSend("pointerdown");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMenuToggle = () => {
|
||||||
|
if (disabled || hasContent) return;
|
||||||
|
if (!isActionMenuOpen) textareaRef.current?.blur();
|
||||||
|
setIsActionMenuOpen((open) => !open);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePromotion = (promotionType: "image" | "voice") => {
|
||||||
|
if (disabled) return;
|
||||||
|
dispatch({
|
||||||
|
type: "ChatPromotionInjected",
|
||||||
|
promotion: createPendingChatPromotion(
|
||||||
|
promotionType,
|
||||||
|
character.id,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
setIsActionMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={barRef} className={styles.bar}>
|
||||||
ref={barRef}
|
<div className={styles.composer}>
|
||||||
className={`${styles.bar} ${isFocused ? styles.barFocused : ""}`}
|
{isActionMenuOpen ? (
|
||||||
>
|
<ChatComposerActionMenu
|
||||||
<div className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}>
|
id={CHAT_ACTION_MENU_ID}
|
||||||
<ChatInputTextField
|
disabled={disabled}
|
||||||
ref={textareaRef}
|
tipHref={characterRoutes.tip}
|
||||||
value={input}
|
onImage={() => handlePromotion("image")}
|
||||||
onChange={handleInputChange}
|
onVoice={() => handlePromotion("voice")}
|
||||||
onSubmit={() => handleSend("keyboard")}
|
onNavigate={() => setIsActionMenuOpen(false)}
|
||||||
onFocusChange={setIsFocused}
|
/>
|
||||||
disabled={disabled}
|
) : null}
|
||||||
/>
|
<div
|
||||||
<ChatSendButton
|
className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}
|
||||||
disabled={disabled}
|
>
|
||||||
hasContent={hasContent}
|
<ChatInputTextField
|
||||||
onClick={() => handleSend("click")}
|
ref={textareaRef}
|
||||||
onPointerDownSend={handlePointerDownSend}
|
value={input}
|
||||||
/>
|
onChange={handleInputChange}
|
||||||
|
onSubmit={() => handleSend("keyboard")}
|
||||||
|
onFocusChange={handleFocusChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<ChatSendButton
|
||||||
|
disabled={disabled}
|
||||||
|
hasContent={hasContent}
|
||||||
|
isMenuOpen={isActionMenuOpen}
|
||||||
|
menuId={CHAT_ACTION_MENU_ID}
|
||||||
|
onClick={() => handleSend("click")}
|
||||||
|
onMenuToggle={handleMenuToggle}
|
||||||
|
onPointerDownSend={handlePointerDownSend}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const ChatInputTextField = forwardRef<
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-auto items-center rounded-full bg-white px-(--spacing-lg,16px)">
|
<div className="flex min-w-0 flex-auto items-center rounded-full bg-transparent px-[clamp(10px,3vw,14px)]">
|
||||||
<textarea
|
<textarea
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
className="min-h-(--chat-send-button-size,40px) max-h-[min(30vh,120px)] w-full min-w-0 flex-auto resize-none border-0 bg-transparent pb-0 pl-0 pr-0 pt-[clamp(5px,1.111vw,6px)] font-[inherit] text-[16px] leading-[clamp(22px,4.444vw,24px)] text-(--color-text-foreground,#000) caret-accent outline-none placeholder:text-(--color-text-hint,#757575)"
|
className="min-h-(--chat-send-button-size,40px) max-h-[min(30vh,120px)] w-full min-w-0 flex-auto resize-none border-0 bg-transparent pb-0 pl-0 pr-0 pt-[clamp(5px,1.111vw,6px)] font-[inherit] text-[16px] leading-[clamp(22px,4.444vw,24px)] text-(--color-text-foreground,#000) caret-accent outline-none placeholder:text-(--color-text-hint,#757575)"
|
||||||
|
|||||||
@@ -1,29 +1,43 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ArrowUp } from "lucide-react";
|
import { ArrowUp, Plus, X } from "lucide-react";
|
||||||
|
|
||||||
export interface ChatSendButtonProps {
|
export interface ChatSendButtonProps {
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
hasContent: boolean;
|
hasContent: boolean;
|
||||||
|
isMenuOpen: boolean;
|
||||||
|
menuId: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onMenuToggle: () => void;
|
||||||
onPointerDownSend: () => void;
|
onPointerDownSend: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatSendButton({
|
export function ChatSendButton({
|
||||||
disabled,
|
disabled,
|
||||||
hasContent,
|
hasContent,
|
||||||
|
isMenuOpen,
|
||||||
|
menuId,
|
||||||
onClick,
|
onClick,
|
||||||
|
onMenuToggle,
|
||||||
onPointerDownSend,
|
onPointerDownSend,
|
||||||
}: ChatSendButtonProps) {
|
}: ChatSendButtonProps) {
|
||||||
const isActive = hasContent && !disabled;
|
const isSendMode = hasContent;
|
||||||
|
const label = isSendMode
|
||||||
|
? "Send message"
|
||||||
|
: isMenuOpen
|
||||||
|
? "Close chat actions"
|
||||||
|
: "Open chat actions";
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-analytics-ignore
|
data-analytics-ignore={isSendMode ? true : undefined}
|
||||||
|
data-analytics-key={isSendMode ? undefined : "chat.toggle_actions"}
|
||||||
className={[
|
className={[
|
||||||
"flex aspect-square size-(--chat-send-button-size,40px) shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-(--color-button-gradient-end,#fc69df) text-white transition-[background,transform] duration-200 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]",
|
"flex aspect-square size-(--chat-send-button-size,42px) shrink-0 cursor-pointer items-center justify-center rounded-full border transition-[background,color,transform,box-shadow] duration-200 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#f657a0]",
|
||||||
isActive
|
isSendMode
|
||||||
? "bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))]"
|
? "border-transparent bg-[linear-gradient(to_right,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] text-white shadow-[0_8px_20px_rgba(246,87,160,0.3)]"
|
||||||
: "bg-[#f8a8ce] text-[rgba(255,255,255,0.88)] shadow-none",
|
: isMenuOpen
|
||||||
|
? "border-transparent bg-[#38262d] text-white shadow-[0_8px_18px_rgba(56,38,45,0.18)]"
|
||||||
|
: "border-[rgba(104,67,80,0.09)] bg-[#f8f1f4] text-[#76505f] shadow-none",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
@@ -32,17 +46,21 @@ export function ChatSendButton({
|
|||||||
if (event.pointerType === "mouse") return;
|
if (event.pointerType === "mouse") return;
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!hasContent) return;
|
if (!isSendMode) return;
|
||||||
onPointerDownSend();
|
onPointerDownSend();
|
||||||
}}
|
}}
|
||||||
onClick={onClick}
|
onClick={isSendMode ? onClick : onMenuToggle}
|
||||||
aria-label="Send message"
|
aria-label={label}
|
||||||
|
aria-expanded={isSendMode ? undefined : isMenuOpen}
|
||||||
|
aria-controls={isSendMode ? undefined : menuId}
|
||||||
>
|
>
|
||||||
<ArrowUp
|
{isSendMode ? (
|
||||||
className="size-(--icon-size-xl,24px) text-(length:--icon-size-xl,24px) leading-none"
|
<ArrowUp size={23} strokeWidth={2.4} aria-hidden="true" />
|
||||||
size={24}
|
) : isMenuOpen ? (
|
||||||
aria-hidden="true"
|
<X size={22} strokeWidth={2.2} aria-hidden="true" />
|
||||||
/>
|
) : (
|
||||||
|
<Plus size={23} strokeWidth={2.2} aria-hidden="true" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import memoryDriver from "unstorage/drivers/memory";
|
|||||||
import { StorageKeys } from "@/data/storage/storage_keys";
|
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||||
import { SessionAsyncUtil } from "@/utils/session-storage";
|
import { SessionAsyncUtil } from "@/utils/session-storage";
|
||||||
|
|
||||||
import { NavigationStorage } from "../navigation_storage";
|
import {
|
||||||
|
createPendingChatPromotion,
|
||||||
|
NavigationStorage,
|
||||||
|
} from "../navigation_storage";
|
||||||
|
|
||||||
const CHARACTER_ID = "elio";
|
const CHARACTER_ID = "elio";
|
||||||
const OTHER_CHARACTER_ID = "maya-tan";
|
const OTHER_CHARACTER_ID = "maya-tan";
|
||||||
@@ -105,6 +108,28 @@ describe("NavigationStorage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates manual promotions without persisting them", async () => {
|
||||||
|
const image = createPendingChatPromotion("image", CHARACTER_ID);
|
||||||
|
const voice = createPendingChatPromotion("voice", CHARACTER_ID);
|
||||||
|
|
||||||
|
expect(image).toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
promotionType: "image",
|
||||||
|
lockType: "image_paywall",
|
||||||
|
});
|
||||||
|
expect(voice).toMatchObject({
|
||||||
|
characterId: CHARACTER_ID,
|
||||||
|
promotionType: "voice",
|
||||||
|
lockType: "voice_message",
|
||||||
|
});
|
||||||
|
expect(image.clientLockId).toMatch(/^promotion_/);
|
||||||
|
expect(voice.clientLockId).toMatch(/^promotion_/);
|
||||||
|
expect(voice.clientLockId).not.toBe(image.clientLockId);
|
||||||
|
await expect(
|
||||||
|
NavigationStorage.consumePendingChatPromotion(CHARACTER_ID),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
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({
|
||||||
characterId: CHARACTER_ID,
|
characterId: CHARACTER_ID,
|
||||||
|
|||||||
@@ -69,6 +69,19 @@ export type PendingChatImageReturn = z.output<
|
|||||||
typeof PendingChatImageReturnSchema
|
typeof PendingChatImageReturnSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export function createPendingChatPromotion(
|
||||||
|
promotionType: PendingChatPromotionType,
|
||||||
|
characterId: string,
|
||||||
|
): PendingChatPromotion {
|
||||||
|
return PendingChatPromotionSchema.parse({
|
||||||
|
characterId,
|
||||||
|
promotionType,
|
||||||
|
lockType: toPromotionLockType(promotionType),
|
||||||
|
clientLockId: `promotion_${createUuidV4()}`,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NavigationStorage owns short-lived cross-route sessions.
|
* NavigationStorage owns short-lived cross-route sessions.
|
||||||
*
|
*
|
||||||
@@ -180,13 +193,10 @@ export class NavigationStorage {
|
|||||||
promotionType: PendingChatPromotionType,
|
promotionType: PendingChatPromotionType,
|
||||||
characterId: string,
|
characterId: string,
|
||||||
): Promise<PendingChatPromotion> {
|
): Promise<PendingChatPromotion> {
|
||||||
const promotion: PendingChatPromotion = {
|
const promotion = createPendingChatPromotion(
|
||||||
characterId,
|
|
||||||
promotionType,
|
promotionType,
|
||||||
lockType: toPromotionLockType(promotionType),
|
characterId,
|
||||||
clientLockId: `promotion_${createUuidV4()}`,
|
);
|
||||||
createdAt: Date.now(),
|
|
||||||
};
|
|
||||||
await SessionAsyncUtil.setJson(
|
await SessionAsyncUtil.setJson(
|
||||||
StorageKeys.pendingChatPromotion,
|
StorageKeys.pendingChatPromotion,
|
||||||
promotion,
|
promotion,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
createPendingChatPromotion as createPromotion,
|
||||||
NavigationStorage,
|
NavigationStorage,
|
||||||
type PendingChatUnlock,
|
type PendingChatUnlock,
|
||||||
type PendingChatUnlockKind,
|
type PendingChatUnlockKind,
|
||||||
@@ -17,6 +18,13 @@ export type {
|
|||||||
PendingChatUnlockStage,
|
PendingChatUnlockStage,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function createPendingChatPromotion(
|
||||||
|
promotionType: PendingChatPromotionType,
|
||||||
|
characterId: string,
|
||||||
|
): PendingChatPromotion {
|
||||||
|
return createPromotion(promotionType, characterId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function savePendingChatUnlock(input: {
|
export async function savePendingChatUnlock(input: {
|
||||||
characterId: string;
|
characterId: string;
|
||||||
displayMessageId?: string;
|
displayMessageId?: string;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
|
|||||||
requiredCredits: 0,
|
requiredCredits: 0,
|
||||||
shortfallCredits: 0,
|
shortfallCredits: 0,
|
||||||
historyLoaded: true,
|
historyLoaded: true,
|
||||||
|
networkHistoryLoaded: true,
|
||||||
historyTotal: 0,
|
historyTotal: 0,
|
||||||
historyLimit: 50,
|
historyLimit: 50,
|
||||||
nextHistoryOffset: 0,
|
nextHistoryOffset: 0,
|
||||||
@@ -267,6 +268,8 @@ describe("chat history pagination helpers", () => {
|
|||||||
limit: 0,
|
limit: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(nextState.networkHistoryLoaded).toBe(true);
|
||||||
|
|
||||||
expect(nextState.historyLimit).toBe(50);
|
expect(nextState.historyLimit).toBe(50);
|
||||||
expect(nextState.nextHistoryOffset).toBe(50);
|
expect(nextState.nextHistoryOffset).toBe(50);
|
||||||
expect(nextState.historyTotal).toBe(120);
|
expect(nextState.historyTotal).toBe(120);
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface ChatState {
|
|||||||
upgradeReason: MachineContext["upgradeReason"];
|
upgradeReason: MachineContext["upgradeReason"];
|
||||||
/** True as soon as a local snapshot or the first network result is renderable. */
|
/** True as soon as a local snapshot or the first network result is renderable. */
|
||||||
historyLoaded: boolean;
|
historyLoaded: boolean;
|
||||||
|
/** True after the network history request has completed or failed. */
|
||||||
|
networkHistoryLoaded: boolean;
|
||||||
hasMoreHistory: boolean;
|
hasMoreHistory: boolean;
|
||||||
isLoadingMoreHistory: boolean;
|
isLoadingMoreHistory: boolean;
|
||||||
unlockHistoryPromptVisible: boolean;
|
unlockHistoryPromptVisible: boolean;
|
||||||
@@ -93,6 +95,7 @@ function selectChatState(state: ChatSnapshot): SelectedChatState {
|
|||||||
upgradePromptVisible: state.context.upgradePromptVisible,
|
upgradePromptVisible: state.context.upgradePromptVisible,
|
||||||
upgradeReason: state.context.upgradeReason,
|
upgradeReason: state.context.upgradeReason,
|
||||||
historyLoaded: state.context.historyLoaded,
|
historyLoaded: state.context.historyLoaded,
|
||||||
|
networkHistoryLoaded: state.context.networkHistoryLoaded,
|
||||||
hasMoreHistory:
|
hasMoreHistory:
|
||||||
state.context.historyLoaded &&
|
state.context.historyLoaded &&
|
||||||
state.context.nextHistoryOffset < state.context.historyTotal,
|
state.context.nextHistoryOffset < state.context.historyTotal,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export interface ChatState {
|
|||||||
shortfallCredits: number;
|
shortfallCredits: number;
|
||||||
/** history 首屏可展示标志(本地快照加载完成即可进入 ready)。 */
|
/** history 首屏可展示标志(本地快照加载完成即可进入 ready)。 */
|
||||||
historyLoaded: boolean;
|
historyLoaded: boolean;
|
||||||
|
/** 网络历史已返回或已明确失败;用于避免过早判定深链消息不存在。 */
|
||||||
|
networkHistoryLoaded: boolean;
|
||||||
historyTotal: number;
|
historyTotal: number;
|
||||||
historyLimit: number;
|
historyLimit: number;
|
||||||
nextHistoryOffset: number;
|
nextHistoryOffset: number;
|
||||||
@@ -80,6 +82,7 @@ export function createInitialChatState(
|
|||||||
requiredCredits: 0,
|
requiredCredits: 0,
|
||||||
shortfallCredits: 0,
|
shortfallCredits: 0,
|
||||||
historyLoaded: false,
|
historyLoaded: false,
|
||||||
|
networkHistoryLoaded: false,
|
||||||
historyTotal: 0,
|
historyTotal: 0,
|
||||||
historyLimit: 50,
|
historyLimit: 50,
|
||||||
nextHistoryOffset: 0,
|
nextHistoryOffset: 0,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function applyNetworkHistoryLoadedOutput(
|
|||||||
ChatState,
|
ChatState,
|
||||||
| "messages"
|
| "messages"
|
||||||
| "historyLoaded"
|
| "historyLoaded"
|
||||||
|
| "networkHistoryLoaded"
|
||||||
| "historyTotal"
|
| "historyTotal"
|
||||||
| "historyLimit"
|
| "historyLimit"
|
||||||
| "nextHistoryOffset"
|
| "nextHistoryOffset"
|
||||||
@@ -47,6 +48,7 @@ export function applyNetworkHistoryLoadedOutput(
|
|||||||
return {
|
return {
|
||||||
messages: [...output.messages, ...optimisticTail],
|
messages: [...output.messages, ...optimisticTail],
|
||||||
historyLoaded: true,
|
historyLoaded: true,
|
||||||
|
networkHistoryLoaded: true,
|
||||||
historyTotal: Math.max(0, output.total),
|
historyTotal: Math.max(0, output.total),
|
||||||
historyLimit,
|
historyLimit,
|
||||||
nextHistoryOffset: historyLimit,
|
nextHistoryOffset: historyLimit,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const markHistoryLoadFailedAction = baseChatMachineSetup.assign(
|
|||||||
const characterErrorCode = getCharacterErrorCode(event.error);
|
const characterErrorCode = getCharacterErrorCode(event.error);
|
||||||
return {
|
return {
|
||||||
historyLoaded: true,
|
historyLoaded: true,
|
||||||
|
networkHistoryLoaded: true,
|
||||||
characterErrorCode,
|
characterErrorCode,
|
||||||
canSendMessage:
|
canSendMessage:
|
||||||
characterErrorCode === "CHARACTER_DISABLED" ||
|
characterErrorCode === "CHARACTER_DISABLED" ||
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
chatDispatch: vi.fn(),
|
||||||
|
getLoginToken: vi.fn(async () => ({
|
||||||
|
success: true as const,
|
||||||
|
data: "email-token",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/data/storage/auth", () => ({
|
||||||
|
AuthStorage: {
|
||||||
|
getInstance: () => ({ getLoginToken: mocks.getLoginToken }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/auth/auth-context", () => ({
|
||||||
|
selectAuthIsLoading: () => false,
|
||||||
|
useAuthSelector: (selector: (state: unknown) => unknown) =>
|
||||||
|
selector({
|
||||||
|
context: {
|
||||||
|
hasInitialized: true,
|
||||||
|
loginStatus: "email",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/chat/chat-context", () => ({
|
||||||
|
useChatDispatch: () => mocks.chatDispatch,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ChatAuthSync } from "../chat-auth-sync";
|
||||||
|
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
let root: ReturnType<typeof createRoot> | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) act(() => root?.unmount());
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
root = null;
|
||||||
|
mocks.chatDispatch.mockReset();
|
||||||
|
mocks.getLoginToken.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChatAuthSync", () => {
|
||||||
|
it("dispatches the authenticated session after StrictMode restarts the effect", async () => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<StrictMode>
|
||||||
|
<ChatAuthSync />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.chatDispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.chatDispatch).toHaveBeenCalledWith({
|
||||||
|
type: "ChatUserLogin",
|
||||||
|
token: "email-token",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,14 +34,15 @@ export function ChatAuthSync() {
|
|||||||
|
|
||||||
const sessionKey = authState.loginStatus;
|
const sessionKey = authState.loginStatus;
|
||||||
if (prevSessionKeyRef.current === sessionKey) return;
|
if (prevSessionKeyRef.current === sessionKey) return;
|
||||||
prevSessionKeyRef.current = sessionKey;
|
|
||||||
|
|
||||||
if (authState.loginStatus === "notLoggedIn") {
|
if (authState.loginStatus === "notLoggedIn") {
|
||||||
|
prevSessionKeyRef.current = sessionKey;
|
||||||
chatDispatch({ type: "ChatLogout" });
|
chatDispatch({ type: "ChatLogout" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authState.loginStatus === "guest") {
|
if (authState.loginStatus === "guest") {
|
||||||
|
prevSessionKeyRef.current = sessionKey;
|
||||||
chatDispatch({ type: "ChatGuestLogin" });
|
chatDispatch({ type: "ChatGuestLogin" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,7 @@ export function ChatAuthSync() {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const tokenR = await AuthStorage.getInstance().getLoginToken();
|
const tokenR = await AuthStorage.getInstance().getLoginToken();
|
||||||
if (!cancelled && tokenR.success && tokenR.data) {
|
if (!cancelled && tokenR.success && tokenR.data) {
|
||||||
|
prevSessionKeyRef.current = sessionKey;
|
||||||
chatDispatch({
|
chatDispatch({
|
||||||
type: "ChatUserLogin",
|
type: "ChatUserLogin",
|
||||||
token: tokenR.data,
|
token: tokenR.data,
|
||||||
|
|||||||
@@ -29,12 +29,13 @@ export function ChatPaymentSuccessSync() {
|
|||||||
|
|
||||||
const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`;
|
const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`;
|
||||||
if (lastPaidKeyRef.current === paidKey) return;
|
if (lastPaidKeyRef.current === paidKey) return;
|
||||||
lastPaidKeyRef.current = paidKey;
|
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (await hasPendingChatUnlock(characterId)) return;
|
const hasPendingUnlock = await hasPendingChatUnlock(characterId);
|
||||||
if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" });
|
if (cancelled) return;
|
||||||
|
lastPaidKeyRef.current = paidKey;
|
||||||
|
if (!hasPendingUnlock) chatDispatch({ type: "ChatPaymentSucceeded" });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user