DAO MESSAGE — Vibecoding 建造指南
使用说明: 将本文档全文粘贴给 AI,说"请按照这份指南从零构建工程"即可。
本文由三部分组成:架构原则 + SDK API 参考(机器可读) + 接线示例
Part A: 架构原则(10 条铁律)
DAO MESSAGE 不是一个 App,而是一套 AI 时代的隐私通讯协议 + 中继网络即服务。 你正在帮用户构建的,是一个基于 DAO MESSAGE 协议的专属聊天客户端。
SDK 安装
npm install @daomessage_sdk/sdkSDK 已发布到 npm 公共仓库,直接
npm install即可。无需本地 link、submodule 或 monorepo 配置。 源码仓库:https://github.com/daomessage/securechat-sdk-typescript
| # | 铁律 | 约束 |
|---|---|---|
| 1 | SDK 是黑盒 | @daomessage_sdk/sdk 处理所有加密、密钥、WebSocket、IndexedDB。App 只做 UI 壳。不要重复实现 SDK 已有的任何功能。 |
| 2 | 协议至上 | 所有客户端共享同一套 WS 帧格式和加密协议,你不能自创私有协议 |
| 3 | 绝对零信任 | 服务端只转发密文,App 层不得发送任何明文到服务端 |
| 4 | API 已硬编码 | SecureChatClient 构造函数无参数,API 地址 https://relay.daomessage.com 硬编码在 SDK 内,不可传参、不可覆盖 |
| 5 | 命名二元规则 | HTTP 接口返回 snake_case(如 alias_id, friendship_id);IndexedDB 存储 camelCase(如 conversationId, theirAliasId)。不得混用 |
| 6 | 只用 SDK 导出的 API | 不要猜测或发明 SDK 不存在的方法。所有可用方法见下方 Part B 的 TypeScript 声明 |
| 7 | 事件驱动 | 用 client.on('message' | 'status_change' | 'network_state' | 'typing' | 'goaway', handler) 订阅事件,返回 unsubscribe 函数 |
| 8 | 异步安全 | 所有 SDK 异步调用必须 try/catch,UI 层禁止 Silent Failure。按钮必须有 loading/disabled 状态防止重复点击 |
| 9 | 全面出海 | 面向海外用户,UI 默认英文 |
| 10 | Vibe Coding 生态 | 你生成的 App 必须通过 SDK 接入官方中继,与其他开发者的 App 互通 |
| 11 | 强制双边核对(不可绕过) | 每对会话必须双方都完成密钥核对(trustState='verified')才能聊天。未核对前:(a) client.messages.send() 会抛 UNVERIFIED_SESSION 异常;(b) 收到的密文进 quarantine 队列不解密;(c) 服务端拒绝转发。UI 必须实现三状态可视化:unverified(黄色硬遮罩 + "开始核对"按钮)/ my_side_verified(蓝色软遮罩 + "等对方核对"提示)/ verified(解锁聊天 + 绿色已核验图标)。详见 Part C.9。禁止绕过:不要让用户跳过核对,不要让 trustState 直接从 unverified 跳到 verified。 |
技术栈(强制)
| 层 | 技术 | 版本 |
|---|---|---|
| 构建工具 | Vite | 6+ |
| 前端框架 | React | 19+ |
| 样式方案 | TailwindCSS + @tailwindcss/vite 插件 | v4(不是 v3) |
| 状态管理 | Zustand | 5+ |
| 路由 | React Router | 7+ |
| 语言 | TypeScript(strict mode) | 5.5+ |
构建优先级
按以下顺序实现,每个阶段完成可运行后再进入下一阶段。所有 7 个阶段都是必须交付的,不可跳过任何一个。
- 工程骨架 — Vite 初始化 + TailwindCSS v4 + PWA 配置(manifest + Service Worker) + 路由 + SDK 单例
- 注册/登录 — 助记词生成 + 备份确认 + 注册 + 靓号选购(可跳过) + 恢复会话
- 通讯录 — 好友列表 + 添加好友 + 好友请求处理 + 安全码核验 + 我的二维码 + 扫一扫加好友
- 文字聊天 — 消息收发 + 历史记录 + 已读回执 + 正在输入
- 多媒体 — 图片/文件/语音消息(含 MediaRecorder 录音 UI + VoiceBubble 播放组件)
- 频道 — 频道列表 + 订阅 + 发帖
- 音视频通话 — 完整的 CallScreen 来电/通话/挂断 UI + WebRTC 信令 + STUN/TURN(⚠️ 必须实现,不可跳过)。TURN 后端中立:relay-server
GET /api/v1/calls/ice-config返回标准 WebRTC iceServers JSON,默认接入 Cloudflare Realtime TURN,兼容自建 coturn。详见sdk-typescript/docs/calls.md。
Part B: SDK TypeScript 类型声明(机器可读合约)
以下是
@daomessage_sdk/sdk的完整.d.ts类型声明文件。 AI 必须严格按照这些类型签名调用 SDK,不存在的方法不能调用。 这是单一真相源——如果本文任何其他描述与此.d.ts冲突,以.d.ts为准。
// ═══════════════════════════════════════════════
// @daomessage_sdk/sdk — 完整类型声明
// 自动生成自 sdk-typescript/dist/index.d.ts
// ═══════════════════════════════════════════════
/**
* keys/index.ts - SDK 密钥体系
*
* 架构设计 §1.3.1 HD 派生路径规范:
* - m/44'/0'/0'/0/0 → Ed25519(身份认证/签名)
* - m/44'/1'/0'/0/0 → X25519(ECDH 消息加密)
*
* 依赖:
* - @scure/bip39:助记词生成/验证
* - @noble/curves/ed25519:Ed25519 签名
* - @noble/curves/x25519:X25519 ECDH
* - @noble/hashes/sha512:PBKDF KDF
*/
interface KeyPair {
privateKey: Uint8Array;
publicKey: Uint8Array;
}
interface Identity {
mnemonic: string;
/** Ed25519 身份密钥,用于 Challenge-Response 认证 */
signingKey: KeyPair;
/** X25519 ECDH 密钥,用于消息会话密钥协商 */
ecdhKey: KeyPair;
}
/** 生成 12 词英文助记词 */
declare function newMnemonic(): string;
/** 验证助记词是否合法(12 词,BIP-39 词库)*/
declare function validateMnemonicWords(mnemonic: string): boolean;
/**
* 从助记词完整派生 Identity(包含两对密钥)
*/
declare function deriveIdentity(mnemonic: string): Identity;
/**
* ECDH 密钥协商:根据己方私钥和对方 X25519 公钥计算 SharedSecret
* SharedSecret 再经 KDF 得到 32 字节 AES-256 会话密钥
*/
declare function computeSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array;
/**
* 计算 60 字符安全码
* 算法:SHA-256(min(pubA, pubB) ‖ max(pubA, pubB))[0..30] → hex
* 双方使用相同的确定性拼接顺序,MITM 无法伪造一致结果
*/
declare function computeSecurityCode(myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): string;
/**
* formatSecurityCode — 把 60 hex 字符切成 5 字符 × 12 组的展示格式
*
* 用户看到的形式: "a3f2c 8b91d 4567a ..." (12 组用空格分隔)
* 用于"高级模式: 完整 60 位指纹"展示, 主流程已改用 directional code(见下)。
*/
declare function formatSecurityCode(hex60: string): string;
/**
* normalizeSecurityCode — 比对前归一化用户输入
*
* 用户从外部粘贴的码可能含: 空格 / 换行 / Tab / 中文括号 / 标点污染。
* 归一化只保留 hex 字符 (0-9 a-f), 转小写。用于完整 60 位指纹模式。
*/
declare function normalizeSecurityCode(input: string): string;
/**
* 计算单方向核对码 (Base32 编码 80 bits, 16 字符 4-4-4-4 分组显示)
*
* @param sharedSecret X25519 ECDH 共享密钥 (32 字节)
* @param fromPub 发送方 X25519 公钥 (32 字节)
* @param toPub 接收方 X25519 公钥 (32 字节)
* @returns 例如 "5KQF-2T3X-NMRE-W8YA"
*/
declare function computeDirectionalCode(sharedSecret: Uint8Array, fromPub: Uint8Array, toPub: Uint8Array): string;
/**
* 归一化用户输入的 directional code (去除空格/横线/转大写, 只保留 base32 字符)
* 用于核对前的输入清洗。
*/
declare function normalizeDirectionalCode(input: string): string;
/**
* toBase64 — 安全 Base64 编码
*
* 老实现 `btoa(String.fromCharCode(...bytes))` 在大 Uint8Array(>~100KB)上
* 会触发 "Maximum call stack size exceeded"(spread 参数过多)。
* 新实现分片处理 + 合并,支持任意大小。
*/
declare function toBase64(bytes: Uint8Array): string;
/**
* fromBase64 — 严格 Base64 解码
*
* 校验输入是合法 base64(字符集 + 长度是 4 的倍数)。非法输入会抛出
* 而不是静默产生 NaN 字节(老实现对非法字符会 produce Uint8Array with NaN)。
*/
declare function fromBase64(b64: string): Uint8Array;
declare function toHex(bytes: Uint8Array): string;
declare function fromHex(hex: string): Uint8Array;
/**
* keys/store.ts - T-012 IndexedDB 三存储持久化
* 关键数据结构:identity / sessions / offlineInbox
*/
interface StoredIdentity {
uuid: string;
aliasId: string;
nickname: string;
mnemonic: string;
signingPublicKey: string;
ecdhPublicKey: string;
}
/**
* trustState 三档 (1.0.36 强制双边密钥核对):
* - 'unverified': 默认 / 冷启动 / reset 后, 用户从未本地核对成功 (或 IDB 已被清)
* - 'my_side_verified': 用户已在 KeyVerificationModal 比对成功并调过 verify-mark, 但对方还没核对
* - 'verified': 双方都已完成核对 (服务端 NATS friend_verified 触发或 syncFriends 看到 verified_at NOT NULL)
*
* 状态机约束: unverified → verified 必须经过 my_side_verified
* 单独的 NATS 通知不能让 unverified 跳到 verified (防服务端 root 伪造)
*
* SDK gate:
* - unverified / my_side_verified: sendMessage 抛 UNVERIFIED_SESSION, handleIncomingMsg 入 quarantine
* - verified: 正常发收
*/
type SessionTrustState = 'unverified' | 'my_side_verified' | 'verified';
interface SessionRecord {
conversationId: string;
theirAliasId: string;
theirEcdhPublicKey: string;
theirEd25519PublicKey?: string;
sessionKeyBase64: string;
trustState: SessionTrustState;
friendshipId?: number;
createdAt: number;
}
/**
* QuarantinedMessage — 未核对会话收到的密文, 等核对完成后批量重新解密。
* 关键: 不立即解密展示, 因为如果服务端 MITM, 密文是用假 sessionKey 加密的, 内容不可信。
*/
interface QuarantinedMessage {
conversationId: string;
msgId: string;
seq: number;
fromAliasId: string;
payloadEncrypted: string;
envelope: string;
receivedAt: number;
}
declare function loadIdentity(): Promise<StoredIdentity | undefined>;
declare function clearIdentity(): Promise<void>;
declare function loadSession(conversationId: string): Promise<SessionRecord | undefined>;
declare function listSessions(): Promise<SessionRecord[]>;
declare function deleteSession(conversationId: string): Promise<void>;
declare function markSessionVerified(conversationId: string): Promise<void>;
/**
* 用户在 KeyVerificationModal 本地比对成功后调。
* 注: 不直接升 verified, 必须等服务端 NATS friend_verified 通知才升级。
*/
declare function markSessionMyVerified(conversationId: string): Promise<void>;
/**
* 服务端 NATS friend_verified 通知或 syncFriends 看到 verified_at NOT NULL 时调。
* 关键约束: 必须本地已是 my_side_verified 才能升 verified, 否则忽略 (防服务端 root 伪造)。
* 返回是否真的升级了。
*/
declare function maybeMarkSessionVerified(conversationId: string): Promise<boolean>;
/**
* Reset 会话到未核对状态。
* 用户主动重新核对或服务端 NATS friend_verify_reset 触发。
* quarantine 保留 (重新核对成功后还能解密老密文)。
*/
declare function resetSessionTrust(conversationId: string): Promise<void>;
/**
* 用户在 KeyVerificationModal 本地比对成功后调。
* 1) 本地 trustState 升级到 my_side_verified (写 IDB)
* 2) 通知服务端 verify-mark
*
* 注: 不直接升 verified, 必须等服务端 NATS friend_verified 才升级。
* 这是 spec §4.5.2 状态机约束。
*
* 抛错场景: 服务端返回非 2xx (比如 friendship 不在 accepted 状态、被踢出参与者等)
* 本地 IDB 写入失败
*/
declare function markMyVerified(apiBase: string, token: string, friendshipId: number, conversationId: string): Promise<{
myVerifiedAt?: string;
verifiedAt?: string | null;
}>;
/**
* 重置核对状态 (用户主动重新核对)。
* 1) 调服务端 verify-reset
* 2) 本地 trustState 重置为 unverified
*
* 服务端会通过 NATS friend_verify_reset 通知对端, 对端 SDK 也会 reset 本地 trustState。
*/
declare function verifyReset(apiBase: string, token: string, friendshipId: number, conversationId: string): Promise<void>;
interface StoredMessage {
id: string;
conversationId: string;
text: string;
isMe: boolean;
time: number;
status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
msgType?: string;
mediaUrl?: string;
caption?: string;
seq?: number;
fromAliasId?: string;
replyToId?: string;
}
interface OutboxIntent {
internalId: string;
conversationId: string;
toAliasId: string;
text: string;
addedAt: number;
replyToId?: string;
}
type NetworkState = 'connected' | 'connecting' | 'disconnected';
type NetworkListener = (state: NetworkState) => void;
declare class RobustWSTransport implements WSTransport {
private ws;
isConnected: boolean;
private messageHandlers;
private openHandlers;
private closeHandlers;
private networkListeners;
private goawayListeners;
private reconnectAttempts;
private reconnectTimer;
private heartbeatTimer;
private intentionalClose;
lastUrl: string;
private connectingPromise;
constructor();
onNetworkStateChange(fn: NetworkListener): () => void;
/** 监听 GOAWAY 帧(被其他设备踢下线) */
onGoaway(fn: (reason: string) => void): () => void;
private emitNetworkState;
private urlProvider;
connect(urlOrProvider: string | (() => Promise<string>)): void;
private _doConnect;
private _scheduleReconnect;
private _startHeartbeat;
private _stopHeartbeat;
send(data: string): void;
onMessage(handler: (data: string) => void): void;
onOpen(handler: () => void): void;
onClose(handler: () => void): void;
disconnect(): void;
}
/**
* sdk-typescript/src/messaging/index.ts — T-100+T-101
* MessageModule:完整的消息收发封装 + 离线同步引擎 + 强制本地持久化 (Vibe Coding Refactor)
*/
interface OutgoingMessage {
conversationId: string;
toAliasId: string;
text: string;
replyToId?: string;
}
interface MessageStatus {
id: string;
status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
}
interface WSTransport {
send(data: string): void;
onMessage(handler: (data: string) => void): void;
onOpen(handler: () => void): void;
onClose(handler: () => void): void;
isConnected: boolean;
}
declare class MessageModule {
private transport;
onMessage?: (msg: StoredMessage) => void;
onStatusChange?: (status: MessageStatus) => void;
onChannelPost?: (data: any) => void;
/** 对方正在输入通知 */
onTyping?: (data: {
fromAliasId: string;
conversationId: string;
}) => void;
/** 链上支付确认通知(pay-worker 确认后 WS 推送,由 VanityModule 订阅)*/
onPaymentConfirmed?: (data: {
type: string;
order_id: string;
ref_id: string;
}) => void;
/** P0-C(2026-04-26): 通讯录变更通知 — 收到好友请求 / 对方同意你的请求时触发,UI 应重新拉好友列表。
* 服务端在 friends/handler.go 用 PublishCore("msg.notify.{uuid}", ...) 推送,
* gateway 用 NATS 订阅转成 WS 帧 {type:'friend_request'|'friend_accepted', ...}。
* 之前两端用 10s 轮询掩盖这个事件链断点,现在补齐。*/
onContactsChange?: (data: {
type: 'friend_request' | 'friend_accepted';
from?: string;
by?: string;
conv_id?: string;
}) => void;
/**
* 1.0.38: trustState 变化通知 — 三态版。客户端 UI 用此触发"聊天页解锁/阻塞"切换。
* - trustState='verified': 双方都完成核对, UI 解锁聊天
* - trustState='my_side_verified': 本端调 markMyVerified 后, UI 切到"等对方"视图
* - trustState='unverified': 一方 reset, UI 切回阻塞 + 提示重新核对
*
* 历史: 1.0.36/1.0.37 这里只声明了 'verified' | 'unverified', 缺 my_side_verified —
* 但运行时实际并没有从这里发 my_side_verified, UI 端只能自行 loadSession() 兜底。
* 1.0.38 起 markMyVerified 也会触发本回调, 类型补齐。
*/
onTrustStateChange?: (data: {
conversationId: string;
trustState: SessionTrustState;
}) => void;
/**
* 1.0.38: 由独立函数 markMyVerified() 调用, 在本地 trustState 升 my_side_verified
* 后通知 UI。也可由 verifyReset 调用通知 UI 降 unverified。
* 公开方法避免反射 hack — 之前 PWA/Android 用 (client.messages as any).inner.onTrustStateChange
* 都改用这个稳定 API。
*/
notifyTrustStateChange(conversationId: string, trustState: SessionTrustState): void;
constructor(transport: WSTransport);
send(msg: OutgoingMessage): Promise<string>;
private _trySendIntent;
sendDelivered(convId: string, seq: number, toAliasId: string): void;
sendRead(convId: string, seq: number, toAliasId: string): void;
sendTyping(toAliasId: string, convId: string): void;
/** 发送消息撤回帧(架构 §4.2 V1.1 新增) */
sendRetract(messageId: string, toAliasId: string, convId: string): void;
private handleFrame;
/**
* 1.0.36: 处理 quarantine 队列。
* 在双方都核对完成后调用, 把之前缓存的密文重新走 handleIncomingMsg 流程解密。
* 完成后清空 quarantine。
*/
private processQuarantine;
private handleIncomingMsg;
private handleStatusChange;
/**
* 处理 delivered/read 回执帧(基于 conv_id 批量更新)
* 回执帧格式:{type:'delivered'|'read', conv_id, seq, to}
* 不含消息 id,所以需要按 conv_id 查找自己发出的消息并更新
*/
private handleReceiptByConvId;
private onConnected;
private handleRetract;
}
declare class HttpClient {
private apiBase;
private token;
constructor(apiBase?: string);
setApiBase(apiBase: string): void;
getApiBase(): string;
setToken(token: string | null): void;
getToken(): string | null;
getHeaders(customHeaders?: Record<string, string>): Record<string, string>;
get<T = any>(path: string): Promise<T>;
post<T = any>(path: string, body: any): Promise<T>;
put<T = any>(path: string, body?: any): Promise<T>;
delete<T = any>(path: string): Promise<T>;
/**
* For direct fetch calls (like Media Presigned URL PUT / GET)
*/
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
}
declare class AuthModule {
private http;
private _uuid;
/**
* 串行化 restoreSession / reauthenticate,防止并发自踢:
* 多个调用点(冷启动/SW 唤醒/标签恢复)几乎同时调用时,
* await 之间的微任务窗口让两个 promise 都先读到 token == null,
* 各跑一次 authenticate,服务端 revokeOldSessions 把先到的 JTI 撤销,
* 30s 后 revalidateLoop 推 GOAWAY → 误踢。
*/
private authPromise;
constructor(http: HttpClient);
/**
* 供 SDK 内部建立 WebSocket 时调用,防误用
*/
get internalUUID(): string;
/**
* 恢复会话:从本地 IndexedDB 加载身份 -> 防伪签名挑战 -> 写入 Token
*
* 🔁 幂等保证(关键!):
* 服务端 revokeOldSessions 每次新认证都会撤销旧 JTI。
* 短时间内多处调用 restoreSession(冷启动 / SW 唤醒 / 标签切换),
* 第一个 JTI 会被第二个撤销,30 秒后 revalidateLoop 命中导致 jwt_revoked GOAWAY。
* 因此:已经有 token 时直接复用,只在第一次或主动 logout 后才重新认证。
*/
restoreSession(): Promise<{
aliasId: string;
nickname: string;
} | null>;
/**
* 强制重新认证(用于 jwt_revoked 自愈场景)
* 不复用现有 token,始终走完整 authenticate 拿新 JTI。
* 但仍要和 restoreSession 共享同一把"in-flight"锁,避免和并发的 restoreSession 互相撤 JTI。
*/
reauthenticate(): Promise<{
aliasId: string;
nickname: string;
} | null>;
/**
* 注册:执行 PoW 防刷验证 -> 计算公钥 -> /register -> /auth/challenge -> /auth/verify
* V1.4.1 方案 A:靓号在注册完成后通过 vanity.bind() 接口绑定,注册时不再传入靓号订单
*/
registerAccount(mnemonic: string, nickname: string): Promise<{
aliasId: string;
}>;
/**
* 针对给定的 UUID 和私钥执行防伪鉴权,成功后将 token 注册到 http 内部并返回
*/
performAuthChallenge(userUUID: string, signingPrivateKey: Uint8Array): Promise<string>;
}
/**
* Observable<T> — 0.3.0 响应式数据层原语
*
* 设计目标:
* - bundle size ≤ 3KB gzipped(不引入 rxjs)
* - 语义与 Kotlin StateFlow / Swift AsyncStream 对齐
* - 零依赖
*
* 语义约定:
* - 只实现 "hot" 流(BehaviorSubject 语义):新订阅者立即收到当前值
* - emit 顺序严格,背压策略 = 最新覆盖(IM 场景合适)
* - unsubscribe 幂等,多次调用无副作用
*
* 不做什么:
* - 不做 rxjs 全集(Subject 以外的各种高级操作符)
* - 不做 cold observable(所有流一经创建即 hot)
* - 不做错误信道(错误走 client.events.error 总线)
*/
interface Subscription {
unsubscribe(): void;
readonly closed: boolean;
}
interface Observer<T> {
next?: (value: T) => void;
error?: (err: Error) => void;
complete?: () => void;
}
type Subscribable<T> = Observer<T> | ((value: T) => void);
interface Observable<T> {
subscribe(observer: Subscribable<T>): Subscription;
/** BehaviorSubject 语义:当前值(订阅时立即收到) */
readonly value: T;
/** 操作符:映射 */
map<U>(fn: (value: T) => U): Observable<U>;
/** 操作符:过滤(谓词为 false 的值不发射) */
filter(predicate: (value: T) => boolean): Observable<T>;
/** 操作符:值变化才发射(浅比较) */
distinctUntilChanged(compare?: (a: T, b: T) => boolean): Observable<T>;
}
/**
* BehaviorSubject<T> — 带当前值的 Observable
* SDK 内部使用,对外一律以 Observable<T> 类型暴露(readonly)
*/
declare class BehaviorSubject<T> implements Observable<T> {
private _value;
private _listeners;
private _completed;
constructor(initial: T);
get value(): T;
/** 内部 API:推送新值 */
next(value: T): void;
/** 内部 API:终结流(之后不再发射,但已有订阅者仍持有最后一个值) */
complete(): void;
subscribe(observer: Subscribable<T>): Subscription;
map<U>(fn: (value: T) => U): Observable<U>;
filter(predicate: (value: T) => boolean): Observable<T>;
distinctUntilChanged(compare?: (a: T, b: T) => boolean): Observable<T>;
}
/**
* src/events — 0.3.0 全局事件总线
*
* 取代 0.2.x 的 client.on('message', cb) 风格,改为四个顶层 Observable:
* - client.events.network: 连接状态
* - client.events.sync: 消息补同步状态
* - client.events.error: 非致命错误
* - client.events.message: 跨会话的全局消息流(UI 通知场景)
*
* 内部实现:包装 BehaviorSubject,对外只暴露 Observable<T> 只读视图
*/
type SyncState = {
tag: 'idle';
} | {
tag: 'syncing';
progress: number;
pendingMessages: number;
} | {
tag: 'done';
catchUpDurationMs: number;
};
type SDKErrorKind = 'auth' | 'network' | 'rate_limit' | 'crypto' | 'server' | 'unknown';
interface SDKError {
kind: SDKErrorKind;
message: string;
details?: Record<string, unknown>;
at: number;
}
/**
* SDK 内部使用的事件总线,持有可写的 BehaviorSubject
* 通过 .toPublic() 产出对外视图
*/
declare class EventBus {
readonly _network: BehaviorSubject<NetworkState>;
readonly _sync: BehaviorSubject<SyncState>;
readonly _error: BehaviorSubject<SDKError | null>;
readonly _message: BehaviorSubject<StoredMessage | null>;
emitNetwork(state: NetworkState): void;
emitSync(state: SyncState): void;
emitError(err: Omit<SDKError, 'at'>): void;
emitMessage(msg: StoredMessage): void;
toPublic(): PublicEventBus;
}
/** 对外只读的事件总线视图 */
interface PublicEventBus {
/** WebSocket 连接状态 · offline | connecting | online */
readonly network: Observable<NetworkState>;
/** 消息补同步状态 · idle | syncing | done */
readonly sync: Observable<SyncState>;
/** 非致命错误流(初值 null) */
readonly error: Observable<SDKError | null>;
/** 全局消息流 · 每条到达的消息(跨会话)· 初值 null */
readonly message: Observable<StoredMessage | null>;
}
/**
* src/contacts/module.ts — 0.4.0 ContactsModule(响应式首版)
*
* 直接命名 ContactsModule, 不叫 ReactiveContactsModule。
* 产品未公开发布, 没有历史包袱, 第一版就是终态。
*/
interface FriendProfile {
friendship_id: number;
alias_id: string;
nickname: string;
status: 'pending' | 'accepted' | 'rejected';
direction: 'sent' | 'received';
conversation_id: string;
x25519_public_key: string;
ed25519_public_key: string;
created_at: string;
}
declare class ContactsModule {
private readonly http;
private readonly events?;
private _friends;
private _primed;
private _refreshPromise;
constructor(http: HttpClient, events?: EventBus | undefined);
observeFriends(): Observable<FriendProfile[]>;
observeAccepted(): Observable<FriendProfile[]>;
observePending(): Observable<FriendProfile[]>;
observePendingCount(): Observable<number>;
/** 快照读取(非订阅) */
get friends(): FriendProfile[];
lookupUser(aliasId: string): Promise<{
alias_id: string;
nickname: string;
x25519_public_key: string;
ed25519_public_key: string;
}>;
/** 发送好友请求,成功后自动 refresh */
sendRequest(toAliasId: string): Promise<void>;
/** 接受好友请求(乐观更新 + rollback),返回 conversationId */
accept(friendshipId: number): Promise<string>;
/** 拒绝好友请求(乐观更新 + rollback) */
reject(friendshipId: number): Promise<void>;
/**
* 兼容 0.2.x API · 一次性拉取好友列表快照
* 指定描述: 底层执行 refresh(), 返回最新 friends 数组
* 推荐新代码用 observeFriends().subscribe() 或 observeFriends().value
*/
/** 兼容 0.2.x API */
acceptFriendRequest(friendshipId: number): Promise<string>;
/** 兼容 0.2.x API */
sendFriendRequest(toAliasId: string): Promise<void>;
/** 兼容 0.2.x API */
rejectFriendRequest(friendshipId: number): Promise<void>;
syncFriends(): Promise<FriendProfile[]>;
/** 手动触发刷新(WS 收到好友事件时 SDK 内部调用) */
refresh(): Promise<void>;
private _refresh;
private _reportError;
}
/**
* src/messaging/module.ts — 0.4.0 MessagesModule(响应式)
*
* 底层保留 MessageModule(单数, index.ts 里的)作为 WebSocket / Outbox 引擎。
* 本类是对外 API, 暴露 observeConversations / observeMessages / send。
*/
/** 1.0.38 公开三态 trustState 回调签名 — UI 用此监听核对状态切换。 */
type TrustStateChangeData = {
conversationId: string;
trustState: SessionTrustState;
};
type TrustStateChangeListener = (data: TrustStateChangeData) => void;
interface ConversationSummary {
conversationId: string;
peerAliasId: string;
peerNickname: string;
lastMessage?: {
text: string;
at: number;
fromMe: boolean;
status?: StoredMessage['status'];
};
unreadCount: number;
}
declare class MessagesModule {
private readonly inner;
private readonly events?;
private _conversations;
private _byConvId;
private _primed;
private _trustStateListeners;
constructor(inner: MessageModule, events?: EventBus | undefined);
/**
* 1.0.38 订阅 trustState 三态变化。返回 unsubscribe 函数。
*
* 触发时机:
* - `markMyVerified()` 后 → trustState='my_side_verified'
* - 服务端 NATS friend_verified → trustState='verified'(仅在本端已 my_side_verified 时)
* - `verifyReset()` 或服务端 NATS friend_verify_reset → trustState='unverified'
*
* 用法(替代 1.0.37 及更早的反射 hack):
* ```ts
* const unsub = client.messages.onTrustStateChange(({ conversationId, trustState }) => {
* if (conversationId !== activeChatId) return
* if (trustState === 'verified') { setShowModal(false); unlockChat() }
* else if (trustState === 'my_side_verified') { switchToWaitingView() }
* else { showVerifyOverlay() }
* })
* useEffect(() => unsub, [])
* ```
*/
onTrustStateChange(listener: TrustStateChangeListener): () => void;
observeConversations(): Observable<ConversationSummary[]>;
observeMessages(conversationId: string): Observable<StoredMessage[]>;
/** 发送消息,立即写本地,订阅者立刻看到 sending 状态。返回 messageId */
send(msg: OutgoingMessage): Promise<string>;
/** 清除某会话的本地消息(测试/退出账号场景) */
clearConversation(conversationId: string): Promise<void>;
/** 向对方发送正在输入事件 */
sendTyping(convId: string, toAliasId: string): void;
/** 标记对方消息为已读 */
markAsRead(convId: string, maxSeq: number, toAliasId: string): void;
/** 撤回自己发的消息 */
retract(messageId: string, toAliasId: string, conversationId: string): void;
/** 获取本地历史消息 */
getHistory(convId: string, opts?: {
limit?: number;
before?: number;
}): Promise<StoredMessage[]>;
/** 清除指定会话的本地消息 */
clearHistory(convId: string): Promise<void>;
/** 清除全部会话的本地消息 */
clearAllConversations(): Promise<void>;
/** 导出所有会话为 Blob URL(NDJSON) */
exportAll(): Promise<string>;
private _refreshSummary;
private _buildSummary;
private _loadConversation;
private _handleIncoming;
private _handleStatusChange;
}
/**
* sdk-typescript/src/media/manager.ts — 多媒体上传/下载(零知识盲中转)
* 支持图片(压缩)、文件(原始)、语音(原始)三类
*/
declare class MediaModule$1 {
private http;
constructor(http: HttpClient);
/**
* 极简外壳:自动压缩、调用端侧加密、并获得安全返回
* 成功即返回拼接好的快捷消息内容: "[img]media_key"
*/
uploadImage(conversationId: string, file: File, maxDim?: number, quality?: number): Promise<string>;
/**
* 上传通用文件(不压缩,直接加密分片上传)
* 返回 "[file]media_key|original_name|size_bytes"
*/
uploadFile(file: File, conversationId: string): Promise<string>;
/**
* 上传语音消息(不压缩,直接加密分片上传)
* @param durationMs 录音时长(毫秒)
* 返回 "[voice]media_key|duration_ms"
*/
uploadVoice(blob: Blob, conversationId: string, durationMs: number): Promise<string>;
/**
* 分片上传加密大文件 (由于原生 AES-GCM 限制,采用基于 Chunk 的流式加密)
* 返回 "[img]media_key" (此处重用业务层格式)
*/
uploadEncryptedFile(file: File, conversationId: string, maxDim?: number, quality?: number): Promise<string>;
/**
* 通用加密分片上传(共享逻辑)
* @returns media_key
*/
private encryptAndUpload;
/**
* 下载加密的媒体文件 (裸流,仅供内部解密使用)
*/
private downloadMedia;
/**
* 下载并流式解密媒体文件
*/
downloadDecryptedMedia(mediaKey: string, conversationId: string): Promise<ArrayBuffer>;
/**
* 压缩图片到指定最大尺寸(宽/高)
*/
private compressImage;
}
/**
* src/media/module.ts — 0.4.0 MediaModule(响应式上传进度)
*
* 对 0.2.x 老 MediaModule (manager.ts) 的上层包装, 提供:
* - sendImage / sendFile / sendVoice 返回 messageId
* - observeUpload(messageId) 返回 Observable<UploadProgress>
*
* 0.5+ 会把进度回调从伪曲线改成真实 byte-level, 届时接口不变。
*/
type UploadPhase = 'encrypting' | 'uploading' | 'done' | 'failed';
interface UploadProgress {
messageId: string;
phase: UploadPhase;
loaded: number;
total: number;
error?: string;
}
type MediaKind = 'image' | 'file' | 'voice';
declare class MediaModule {
private readonly events?;
private _uploads;
private _inner;
private _messages?;
constructor(low: MediaModule$1, events?: EventBus | undefined, messages?: MessagesModule);
sendImage(conversationId: string, toAliasId: string, file: File, opts?: {
maxDim?: number;
quality?: number;
thumbnail?: string;
replyToId?: string;
}): Promise<string>;
sendFile(conversationId: string, toAliasId: string, file: File, opts?: {
replyToId?: string;
}): Promise<string>;
sendVoice(conversationId: string, toAliasId: string, blob: Blob, durationMs: number, opts?: {
replyToId?: string;
}): Promise<string>;
/** 内部:通过 messages 模块把 payload 作为 IM 消息发给对端 */
private _sendMessage;
observeUpload(messageId: string): Observable<UploadProgress>;
/** 兼容 0.2.x API · 下载并解密媒介 */
downloadDecryptedMedia(mediaKey: string, conversationId: string): Promise<ArrayBuffer>;
/** 释放已完成上传的进度对象 */
dispose(messageId: string): void;
private _upload;
}
/**
* sdk-typescript/src/calls/index.ts — T-072+T-073
* WebRTC 信令状态机 + Insertable Streams E2EE(视频帧加密)
*
* 架构 §4:通话建立流程
* Caller → call_offer(含 SDP + Ed25519 签名)
* → Relay(透明转发)
* → Callee → call_answer / call_reject
* → ICE Candidate 交换
* ← TURN 中继 ← RTP
*/
type CallState = 'idle' | 'calling' | 'ringing' | 'connecting' | 'connected' | 'hangup' | 'rejected' | 'ended';
interface CallOptions {
audio?: boolean;
video?: boolean;
}
interface SignalTransport {
send(env: unknown): void;
onMessage(handler: (env: unknown) => void): void;
}
declare class CallModule {
private transport;
private iceConfigProvider;
private pc;
private callId;
private state;
private localStream;
private remoteStream;
private pendingCandidates;
private _outboundIceBarrier;
private _answerTimer;
private _iceRestartedOnce;
private _disconnectedTimer;
private _rtpWatchdogTimer;
private _lastInboundProgressAt;
private _rtpPrevInbound;
private flushIceCandidates;
/**
* getUserMedia 带超时(不降级):
* - 主请求超过 timeoutMs 没 resolve/reject → 抛 'gUM timeout'
* - reject → 向上抛
*
* ⚠️ 严格模式:用户点"视频通话"就必须真的拿到摄像头。
* 旧版本曾在 video 失败时静默降级到音频纯模式,但后果是:
* 1. PWA 拿到的 stream 只有 audio,addTrack 也只 add 了 audio
* 2. createOffer 仍可能产生 m=video transceiver(unified plan 默认行为)
* 3. 对端 Android 看到 SDP 有 m=video → 也开摄像头并发回视频
* 4. PWA 这边没 sender,但对端却以为是视频通话 — 双方语义错位
* 5. 用户看到"PWA 没弹摄像头权限,Android 也黑屏"
* 因此现在严格行为:video 失败 = 通话失败,UI 必须告知用户"摄像头不可用"。
*
* 修复历史:某些浏览器/环境下 getUserMedia 既不 resolve 也不 reject,
* 用 timeoutMs 做兜底,避免 call() 永远卡在 await。
*/
private getUserMediaWithTimeout;
private signingPrivKey;
private signingPubKey;
private myAliasId;
onStateChange?: (state: CallState) => void;
onRemoteStream?: (stream: MediaStream) => void;
onLocalStream?: (stream: MediaStream) => void;
onIncomingCall?: (fromAlias: string, isVideo: boolean) => void;
onError?: (err: Error) => void;
getLocalStream(): MediaStream | null;
getRemoteStream(): MediaStream | null;
constructor(transport: SignalTransport, iceConfigProvider: () => Promise<RTCConfiguration>, opts: {
signingPrivKey: Uint8Array;
signingPubKey: Uint8Array;
myAliasId: string;
});
call(toAliasId: string, opts?: CallOptions): Promise<void>;
private _answering;
answer(): Promise<void>;
reject(): void;
hangup(): void;
private _callerAlias;
private _remoteAlias;
private handleSignal;
private handleOffer;
private handleAnswer;
/**
* connectionState === 'connected' 后调一次。理由:
* - 协商前 RtpSender.getParameters() 拿不到 codec_payload_type,
* 部分浏览器 setParameters 会抛 InvalidModificationError
* - 接通后 encoding 已就位,改 maxBitrate 最稳
*
* 双端 maxBitrate 必须一致,否则 BWE 取低值,改动等于失效。
* 失败静默(老浏览器 / RN WebView 不一定支持),不能因此断了通话。
*/
private applyVideoSenderParams;
private _diagTimer;
private _diagPrev;
private startDiagDump;
private stopDiagDump;
/**
* 把 SDP 中"本地有对应 track"的 m= section 强制改写为 a=sendrecv。
* 用于修复 Chrome createOffer/createAnswer 在某些场景下生成
* sendonly/recvonly/inactive 导致一端收不到画面的 bug。
*
* - 主叫端 createOffer 后,以 localStream 的 track kinds 为依据修方向
* - 接听端 createAnswer 后同理
*
* 不改 m= section 没有本地 track 的方向(那种 section 本来就该 recvonly)。
*/
private forceSendrecvOnSdp;
private armAnswerTimeout;
private clearAnswerTimeout;
private armDisconnectedTimer;
private clearDisconnectedTimer;
/**
* 接通后周期性 getStats(每 2s),监控入站 RTP 字节/帧是否在涨。
* 视频通话:看 inbound-rtp video.bytesReceived + framesDecoded
* 纯音频:看 inbound-rtp audio.bytesReceived
* 持续 15s 无增长 → cleanup('ended')。
*
* 阈值 15s:Opus DTX 静音可能 5~8s 不发包,15s 留裕度。
* 实测对端 App 被杀场景下,inbound 字节 ~5s 内停止,15s 后兜底触发,体感 OK。
*/
private startRtpWatchdog;
private stopRtpWatchdog;
private handleICE;
private createPeerConnection;
private sendSignal;
private setState;
private cleanup;
private _pubKeyCache;
private fetchPubKey;
private _sessionKeyCache;
private getSessionKey;
}
/**
* src/calls/module.ts — 0.4.0 CallsModule(响应式通话)
*
* 底层 CallModule (calls/index.ts) 保留作为 WebRTC 引擎, 本类是对外 API。
* 响应式流:
* - observeState: CallState('idle' | 'calling' | 'ringing' | 'connecting' | 'connected' | 'hangup' | 'rejected' | 'ended')
* - observeLocalStream / observeRemoteStream
*
* 命令式 API:
* - start(peerAliasId, { audio, video })
* - accept()
* - reject(reason?)
* - hangup()
*/
declare class CallsModule {
private readonly inner;
private readonly events?;
private _state;
private _localStream;
private _remoteStream;
private _extraOnStateChange;
private _extraOnLocalStream;
private _extraOnRemoteStream;
set onStateChange(cb: ((s: CallState) => void) | undefined);
get onStateChange(): ((s: CallState) => void) | undefined;
set onLocalStream(cb: ((s: MediaStream) => void) | undefined);
get onLocalStream(): ((s: MediaStream) => void) | undefined;
set onRemoteStream(cb: ((s: MediaStream) => void) | undefined);
get onRemoteStream(): ((s: MediaStream) => void) | undefined;
set onIncomingCall(cb: ((fromAlias: string, isVideo: boolean) => void) | undefined);
get onIncomingCall(): ((fromAlias: string, isVideo: boolean) => void) | undefined;
set onError(cb: ((err: Error) => void) | undefined);
get onError(): ((err: Error) => void) | undefined;
constructor(inner: CallModule, events?: EventBus | undefined);
observeState(): Observable<CallState>;
observeLocalStream(): Observable<MediaStream | null>;
observeRemoteStream(): Observable<MediaStream | null>;
get currentState(): CallState;
start(peerAliasId: string, options: CallOptions): Promise<void>;
accept(): Promise<void>;
reject(reason?: string): Promise<void>;
hangup(): Promise<void>;
/** 为 PWA 旧 API 提供的别名: start() */
call(peerAliasId: string, options?: CallOptions): Promise<void>;
/** 为 PWA 旧 API 提供的别名: accept() */
answer(): Promise<void>;
/** 获取本地流 */
getLocalStream(): MediaStream | null;
/** 获取远端流 */
getRemoteStream(): MediaStream | null;
}
/**
* security/index.ts — SecurityModule(P3-004 修复)
*
* 实现文档 §2.2.1 SecurityModule 接口设计:
* - getSecurityCode(contactId) → 60位安全码(MITM 防御)
* - verifyInputCode(contactId, code) → 输入验证(主路径)
* - markAsVerified(contactId) → 手动标记已验证(辅助路径)
* - getTrustState(contactId) → 信任状态
* - resetTrustState(contactId) → 重置信任
*
* 所有状态存储于 IndexedDB(服务器完全不知情)
* 防劫持守护:每条消息触发前调用 guardMessage() 检测公钥突变
*/
interface SecurityCode {
contactId: string;
/** 60 位十六进制字符串,每 4 字符一组,如 "AB12 · F39C · ..." */
displayCode: string;
/** 原始 hex(用于 verifyInputCode 内部比对)*/
fingerprintHex: string;
}
type TrustState = {
status: 'unverified';
} | {
status: 'verified';
verifiedAt: number;
fingerprintSnapshot: string;
};
interface SecurityViolationEvent {
type: 'security_violation';
contactId: string;
previousFingerprint: string;
currentFingerprint: string;
detectedAt: number;
message: null;
}
declare class SecurityModule {
/**
* 获取与指定联系人的安全码(60 位 hex 字符串)
* 每次加好友后全自动生成,UI 打开"加密详情"页时调用
*/
getSecurityCode(contactId: string, myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): Promise<SecurityCode>;
/**
* 输入验证(主路径):
* 用户粘贴对方通过微信/TG 发来的 60 位安全码,SDK 自动与本地计算值比对
* 返回 true → 一致(无 MITM)→ 自动写入 verified
* 返回 false → 不一致(公钥被篡改)
*/
verifyInputCode(contactId: string, inputCode: string, myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): Promise<boolean>;
/**
* 手动标记为"已验证"(辅助路径):
* 用户通过截图肉眼比对后,手动点击按钮调用此方法
* 服务器完全不知情(存储于 IndexedDB)
*/
markAsVerified(contactId: string, myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): Promise<void>;
/**
* 获取指定联系人当前的信任状态
*/
getTrustState(contactId: string): Promise<TrustState>;
/**
* 重置验证状态:将 trust_state 还原为 'unverified'
* 场景:用户主动换设备/助记词后需重新核查
*/
resetTrustState(contactId: string): Promise<void>;
/**
* 防劫持守护(文档 §2.2 流程图三):
* 每条消息到达时自动调用,若检测到公钥突变,返回 SecurityViolationEvent
*
* @returns null → 验证通过,可正常解密
* @returns SecurityViolationEvent → 公钥突变,拒绝解密并上报 UI
*/
guardMessage(contactId: string, currentMyEcdh: Uint8Array, currentTheirEcdh: Uint8Array): Promise<SecurityViolationEvent | null>;
}
/**
* src/security/module.ts — 0.4.0 SecurityModule(响应式)
*
* 底层 security/index.ts 里的 SecurityModule(命令式)保留不动,
* 本文件把它包装成响应式 ContactTrust 流。
*
* 重命名:对外类名用 SecurityService 避免与底层 SecurityModule 同名冲突。
*/
declare class SecurityService {
private readonly inner;
private _states;
constructor(inner: SecurityModule);
observeTrust(contactId: string): Observable<TrustState>;
getTrust(contactId: string): Promise<TrustState>;
getSafetyCode(contactId: string, myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): Promise<SecurityCode>;
verifyCode(contactId: string, inputCode: string, myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): Promise<boolean>;
markVerified(contactId: string, myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): Promise<void>;
reset(contactId: string): Promise<void>;
private _loadTrust;
private _emitCurrent;
}
interface ChannelInfo {
id: string;
name: string;
description: string;
role?: string;
is_subscribed?: boolean;
/** 频道是否处于出售状态 */
for_sale?: boolean;
/** 出售价格(USDT),仅当 for_sale=true 时有值 */
sale_price?: number;
}
interface ChannelPost {
id: string;
type: string;
content: string;
created_at: string;
author_alias_id: string;
}
/** 频道交易订单(来自 POST /api/v1/channels/{id}/buy)*/
interface ChannelTradeOrder {
order_id: string;
/** 单位 USDT */
price_usdt: number;
/** TRON 收款地址 */
pay_to: string;
/** 订单有效期(ISO 8601) */
expired_at: string;
}
declare class ChannelsModule {
private http;
constructor(http: HttpClient);
/**
* Search for public channels
*/
search(query: string): Promise<ChannelInfo[]>;
/**
* Get channels subscribed by current user
*/
getMine(): Promise<ChannelInfo[]>;
/**
* Get channel details
*/
getDetail(channelId: string): Promise<ChannelInfo>;
/**
* Create a new channel
*/
create(name: string, description: string, isPublic?: boolean): Promise<{
channel_id: string;
}>;
/**
* Subscribe to a channel
*/
subscribe(channelId: string): Promise<void>;
/**
* Unsubscribe from a channel
*/
unsubscribe(channelId: string): Promise<void>;
/**
* Post a message to a channel
*/
postMessage(channelId: string, content: string, type?: string): Promise<{
post_id: string;
}>;
/**
* Get channel post history
*/
getPosts(channelId: string): Promise<ChannelPost[]>;
/**
* Check if current user can post in the channel
*/
canPost(channelInfo: ChannelInfo | null): boolean;
/**
* 将自有频道挂牌出售(T-096,需要 JWT,必须是频道 Owner)
*
* 使用乐观锁 CAS 设置售价,挂牌后其他用户可通过 `buyChannel` 购买。
*
* @param channelId 要出售的频道 ID
* @param priceUsdt 出售价格(USDT,整数)
*
* @example
* await client.channels.listForSale('ch_abc123', 200)
*/
listForSale(channelId: string, priceUsdt: number): Promise<void>;
/**
* 购买频道 — 创建支付订单(T-096,需要 JWT)
*
* 使用乐观锁 CAS 防止超卖。返回后向用户展示 TRON 收款地址,
* 链上确认后 pay-worker 自动完成频道所有权转移,并推送 `payment_confirmed` WS 事件。
*
* @throws 409 — 频道刚被其他人抢购,请刷新后重试
* @throws 404 — 频道不存在
* @throws 400 — 试图购买自己的频道
*
* @example
* const order = await client.channels.buyChannel('ch_abc123')
* showQRCode(order.pay_to, order.price_usdt)
*/
buyChannel(channelId: string): Promise<ChannelTradeOrder>;
/**
* 购买额外频道创建配额(每次购买增加 1 个席位,固定 5 USDT)
*
* @throws 401 — 需要登录
* @example
* const order = await client.channels.buyQuota()
* showQRCode(order.pay_to, order.price_usdt)
*/
buyQuota(): Promise<ChannelTradeOrder>;
}
/**
* sdk-typescript/src/vanity/manager.ts — T-095 VanityModule
*
* 负责靓号的搜索、购买(创建支付订单)和链上确认回调订阅。
* 支付感知通过 WS `payment_confirmed` 帧 → MessageModule.onPaymentConfirmed → 本模块路由实现。
*
* V1.4.1(方案 A):靓号商店已移至注册完成后,主流程:
* 1. purchase(aliasId) — 注册后购买(需要 JWT),创建 PENDING 订单
* 2. 监听 onPaymentConfirmed() WS 推送 → 链上确认
* 3. bind(orderId) — 支付确认后绑定靓号到账户 alias_id
*
* @deprecated reserve() / orderStatus() 为旧版注册前流程遗留接口,不再使用
*/
/** 靓号列表项(来自 GET /api/v1/vanity/search)
* V1.3 规则引擎版:价格由后端 rules.go 实时评估,不再依赖预填表 */
interface VanityItem {
alias_id: string;
price_usdt: number;
/** 靓号等级:'top' | 'premium' | 'standard' */
tier: string;
is_featured: boolean;
}
/** 购买靓号返回的支付订单(来自 POST /api/v1/vanity/purchase,需 JWT)*/
interface PurchaseOrder {
order_id: string;
alias_id: string;
/** 单位 USDT */
price_usdt: number;
/** NOWPayments 托管支付页 URL(V1.5.0 新增,接入 NOWPayments 后返回)*/
payment_url: string;
/** @deprecated 原始 TRON 地址,NOWPayments 接入后不再使用 */
pay_to?: string;
/** 订单有效期(ISO 8601) */
expired_at: string;
}
/**
* 注册前预订靓号返回的订单(来自 POST /api/v1/vanity/reserve,**无需 JWT**)
* 与 PurchaseOrder 结构相同,但不绑定用户 UUID(注册前无身份)
*/
interface ReserveOrder {
order_id: string;
alias_id: string;
price_usdt: number;
/** TRON 收款地址 */
pay_to: string;
/** 订单有效期(ISO 8601) */
expired_at: string;
}
/**
* 订单状态查询结果(来自 GET /api/v1/vanity/order/{id}/status,**无需 JWT**)
* 用于注册前用户轮询支付结果
*/
interface OrderStatus {
status: 'pending' | 'confirmed' | 'expired';
alias_id: string;
}
/** WS payment_confirmed 事件(pay-worker 链上确认后推送)*/
interface PaymentConfirmedEvent {
type: 'payment_confirmed';
order_id: string;
/** 靓号购买 → alias_id;频道交易 → channel_id */
ref_id: string;
}
declare class VanityModule {
private http;
private paymentListeners;
constructor(http: HttpClient);
/**
* 搜索靓号 / 获取精选列表(T-091 公开接口,无需 JWT)
*
* - `q` 为空 → 返回精选 (is_featured=1),按价格升序
* - `q` 非空 → 按 alias_id 前缀匹配(LIKE 'q%')
*
* @example
* const featured = await client.vanity.search()
* const results = await client.vanity.search('888')
*/
search(q?: string): Promise<VanityItem[]>;
/**
* @deprecated V1.4.1 方案 A 后不再使用。旧版注册前公开预订靓号(无需 JWT)。
* 请改用 purchase()(注册后,需 JWT)+ bind() 流程。
*/
reserve(aliasId: string): Promise<ReserveOrder>;
/**
* @deprecated V1.4.1 方案 A 后不再使用。旧版注册前轮询订单状态(无需 JWT)。
* 注册后请改用 onPaymentConfirmed() WS 推送。
*/
orderStatus(orderId: string): Promise<OrderStatus>;
/**
* 购买靓号 — 创建支付订单(T-090,**需要 JWT**)
*
* V1.4.1 方案 A:注册完成后的首次引导页调用此方法。
* 使用乐观锁 CAS 占位 15 分钟。返回后向用户展示 TRON 收款地址,
* 用户链上转账后 pay-worker 自动完成确认,并通过 WS 推送 `payment_confirmed`。
* 收到推送后,调用 bind(orderId) 将靓号正式绑定到账户。
*
* @throws 409 — 靓号已被其他人抢占,请提示用户更换
* @throws 404 — 靓号不存在
*
* @example
* const order = await client.vanity.purchase('88888888')
* // 展示支付弹窗
* client.vanity.onPaymentConfirmed(async e => {
* const { alias_id } = await client.vanity.bind(e.order_id)
* store.setAliasId(alias_id)
* })
*/
purchase(aliasId: string): Promise<PurchaseOrder>;
/**
* 绑定靓号到当前账户(**V1.4.1 新增,需要 JWT**)
*
* 在 pay-worker 确认链上支付后,调用此方法将 `alias_id` 正式写入 identity 表。
* 通常在 onPaymentConfirmed() 回调内调用。
*
* @param orderId — 已确认的 `payment_order.id`
* @returns `{ alias_id }` — 绑定成功的靓号
*
* @throws 404 — 订单不存在或不属于当前用户
* @throws 409 — 订单未确认 / 靓号绑定冲突
*
* @example
* const { alias_id } = await client.vanity.bind(orderId)
* store.setAliasId(alias_id)
*/
bind(orderId: string): Promise<{
alias_id: string;
}>;
/**
* 订阅支付完成回调(链上确认后 pay-worker → WS 推送)
*
* 返回 unsubscribe 函数,可直接在 React `useEffect` 清理函数中调用。
*
* @example
* useEffect(() => {
* return client.vanity.onPaymentConfirmed(e => {
* toast(`🎉 靓号 ${e.ref_id} 已绑定到你的账号!`)
* router.push('/profile')
* })
* }, [])
*/
onPaymentConfirmed(cb: (e: PaymentConfirmedEvent) => void): () => void;
/**
* @internal SDK 内部路由入口,由 MessageModule handleFrame 在收到
* `payment_confirmed` WS 帧时调用,App 层不应直接调用此方法。
*/
_handlePaymentConfirmed(event: PaymentConfirmedEvent): void;
}
/**
* PushNotificationError — SDK push 模块的语义化错误
*
* 之前 enablePushNotifications 失败只 console.warn,UI 端拿到 generic Error
* 无法定位是哪一步出错。1.0.39 改成抛 PushNotificationError 带 kind 字段,
* UI 可以根据 kind 显示具体修复建议。
*
* kind 列表:
* - 'no_push_manager' SW 不支持 pushManager(老浏览器 / 不安全上下文)
* - 'no_vapid_key' 服务端没返回 VAPID 公钥(服务端配置缺失)
* - 'subscribe_failed' pushManager.subscribe 失败 (iOS 必须 standalone + user gesture)
* - 'permission_denied' 通知权限被拒(用户在系统 / 浏览器设置里拒了)
* - 'register_failed' 服务端 POST /push/subscribe 失败
* - 'unknown' 其他
*/
declare class PushNotificationError extends Error {
readonly kind: 'no_push_manager' | 'no_vapid_key' | 'subscribe_failed' | 'permission_denied' | 'register_failed' | 'unknown';
readonly cause?: unknown;
constructor(kind: PushNotificationError['kind'], message: string, cause?: unknown);
}
declare class PushModule {
private http;
constructor(http: HttpClient);
/**
* 浏览器申请推送凭证并向服务端注册
* 此方法需要依赖浏览器的 ServiceWorker API,仅在 Web 端有效
*
* 失败时抛 PushNotificationError 带 kind 字段,UI 可据此显示具体修复建议。
*/
enablePushNotifications(swRegistration: ServiceWorkerRegistration, vapidPublicKey?: string): Promise<void>;
/**
* 注销当前设备的推送订阅
* 对应服务端 POST /api/v1/push/disable — 清空 device_session.push_endpoint
* 对标 Android/iOS SDK: client.push.disable() / .disablePush()
*/
disablePush(): Promise<void>;
/**
* 1.0.39: 诊断当前推送链路状态, 返回 6 项检查结果。
* 用于 Settings → 推送诊断 UI, 让用户一键自查为什么收不到推送。
*
* 返回 PushDiagnostics 对象, 调用方根据 status 显示状态 + 修复建议。
*/
diagnose(swRegistration?: ServiceWorkerRegistration): Promise<PushDiagnostics>;
private urlBase64ToUint8Array;
}
/** 1.0.39: PushModule.diagnose() 返回值 */
interface PushDiagnostics {
overall: 'ok' | 'warn' | 'bad' | 'unknown';
checks: {
pushApiSupport?: {
ok: boolean;
detail: string;
};
serviceWorker?: {
ok: boolean;
detail: string;
};
permission?: {
ok: boolean;
detail: string;
};
subscription?: {
ok: boolean;
detail: string;
};
serverVapid?: {
ok: boolean;
detail: string;
};
standalone?: {
ok: boolean;
detail: string;
};
};
}
/**
* src/events/streams-ext.ts — 0.4.0 事件总线扩展流
*
* 为什么是扩展文件?
* events/index.ts 里 EventBus 已经有 4 个核心流 (network / sync / error / message)。
* PWA 实际 UI 还依赖:
* - typing 对方输入提示
* - statusChange 消息送达/已读回执
* - channelPost 公共频道新帖
* - goaway 服务端通知下线 (多设备登录被踢等)
*
* 不修改 events/index.ts 的前提下, 用一个 ExtendedEventBus 组合它 + 4 个新流。
* SecureChatClient.events 将是 PublicExtendedEventBus 类型。
*/
interface TypingEvent {
fromAliasId: string;
conversationId: string;
}
interface MessageStatusEvent {
id: string;
status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
}
interface ChannelPostEvent {
channelId: string;
postId: string;
fromAliasId: string;
text: string;
at: number;
[k: string]: any;
}
interface GoawayEvent {
reason: string;
at: number;
}
/** P0-C(2026-04-26): 通讯录变更事件
* - friend_request: 收到他人发来的好友请求(`from` = 对方 user_uuid)
* - friend_accepted: 自己之前发出的请求被对方接受(`by` = 对方 user_uuid, `conv_id` = 新生成的 DM 会话 id)
* UI 应在收到此事件后重新拉取好友列表/请求列表,无需轮询 */
interface ContactsChangeEvent {
type: 'friend_request' | 'friend_accepted';
from?: string;
by?: string;
conv_id?: string;
at: number;
}
interface PublicExtendedEventBus extends PublicEventBus {
/** 对方正在输入 · 初值 null */
readonly typing: Observable<TypingEvent | null>;
/** 消息状态流转(send/delivered/read/failed)· 初值 null */
readonly messageStatus: Observable<MessageStatusEvent | null>;
/** 公共频道新帖 · 初值 null */
readonly channelPost: Observable<ChannelPostEvent | null>;
/** 服务端通知下线(多设备登录被踢 / 强制登出)· 初值 null */
readonly goaway: Observable<GoawayEvent | null>;
/** P0-C: 通讯录变更(收到好友请求 / 对方同意请求)· 初值 null
* UI 应订阅此流并重新拉取好友列表,替代之前的 10s 轮询 */
readonly contactsChange: Observable<ContactsChangeEvent | null>;
}
/**
* src/client-v2.ts — 0.4.0 SecureChatClient(响应式首版, 清爽 API)
*
* ⚠️ 重要:
* 晚上应用 patch 时, 把本文件改名为 client.ts(替换老文件)。
* 本轮 session 规则限制不能直接改 client.ts, 所以并行命名为 v2。
*
* 0.4.0 终态 API:
* const client = new SecureChatClient()
* await client.auth.registerAccount(mnemonic, 'Alice')
* client.contacts.observeFriends().subscribe(handler)
* client.events.network.subscribe(handler)
*
* 删除:
* - attachReactive(client) 门面
* - client.on / client.off EventEmitter API
* - syncFriends / getConversations / 所有命令式 getter
*/
interface SecureChatClientOptions {
/** 自定义 relay URL, 留空用默认 `https://relay.daomessage.com` */
relayUrl?: string;
}
declare class SecureChatClient {
readonly transport: RobustWSTransport;
readonly http: HttpClient;
readonly auth: AuthModule;
readonly contacts: ContactsModule;
readonly messages: MessagesModule;
readonly media: MediaModule;
readonly security: SecurityService;
readonly channels: ChannelsModule;
readonly vanity: VanityModule;
readonly push: PushModule;
calls: CallsModule | null;
readonly events: PublicExtendedEventBus;
private readonly _bus;
private readonly _messageInner;
static readonly CORE_API_BASE = "https://relay.daomessage.com";
constructor(opts?: SecureChatClientOptions);
/** 手动连接 WebSocket(`registerAccount / loginWithMnemonic / restoreSession` 会自动连, 通常不需要手动调) */
/**
* 初始化通话模块(需传入身份签名密钥对)
* 内部则建一个底层 CallModule + 响应式包装 CallsModule
*/
initCalls(opts: {
signingPrivKey: Uint8Array;
signingPubKey: Uint8Array;
myAliasId: string;
alwaysRelay?: boolean;
}): void;
connect(): Promise<void>;
/** 手动断开 WS(调试 / 省电模式) */
disconnect(): void;
get isReady(): boolean;
}
export { type CallOptions, type CallState, CallsModule, type ChannelPostEvent, type ChannelTradeOrder, ChannelsModule, ContactsModule, type ConversationSummary, type FriendProfile, type GoawayEvent, type Identity, type KeyPair, type MediaKind, MediaModule, type MessageStatus, type MessageStatusEvent, MessagesModule, type NetworkState, type Observable, type Observer, type OrderStatus, type OutboxIntent, type OutgoingMessage, type PaymentConfirmedEvent, type PublicEventBus, type PublicExtendedEventBus, type PurchaseOrder, type PushDiagnostics, PushModule, PushNotificationError, type QuarantinedMessage, type ReserveOrder, type SDKError, type SDKErrorKind, SecureChatClient, type SecureChatClientOptions, type SecurityCode, SecurityService, type SessionRecord, type SessionTrustState, type StoredIdentity, type StoredMessage, type Subscribable, type Subscription, type SyncState, type TrustState, type TypingEvent, type UploadPhase, type UploadProgress, type VanityItem, VanityModule, clearIdentity, computeDirectionalCode, computeSecurityCode, computeSharedSecret, deleteSession, deriveIdentity, formatSecurityCode, fromBase64, fromHex, listSessions, loadIdentity, loadSession, markMyVerified, markSessionMyVerified, markSessionVerified, maybeMarkSessionVerified, newMnemonic, normalizeDirectionalCode, normalizeSecurityCode, resetSessionTrust, toBase64, toHex, validateMnemonicWords, verifyReset };Part C: 最小可运行接线示例
以下是将 SDK 接入 App 的关键接线代码。 AI 应以此为模式参考,扩展出完整的聊天应用。
C.1 SDK 单例(全局一个实例)
import { SecureChatClient } from '@daomessage_sdk/sdk';
// 无参实例化——API 地址已硬编码在 SDK 内
export const client = new SecureChatClient();C.2 冷启动流程
// App 启动时 → 尝试恢复会话
const session = await client.restoreSession();
if (!session) {
// 首次使用 → 进入注册流程
navigateTo('welcome');
} else {
// 已注册 → 连接 WebSocket + 同步好友
const { aliasId, nickname } = session;
client.connect().catch(console.warn); // 优先获取 WS ticket 后再建连
await client.contacts.syncFriends();
navigateTo('main');
}C.3 注册流程
import { newMnemonic } from '@daomessage_sdk/sdk';
// Step 1: 生成 12 词助记词(同步函数!)
const mnemonic = newMnemonic();
// → 展示给用户备份
// Step 2: 用户确认备份后注册
const { aliasId } = await client.auth.registerAccount(mnemonic, nickname);
// → SDK 自动完成:PoW → 密钥派生 → HTTP 注册 → JWT 获取
// Step 3: 连接
client.connect().catch(console.warn);C.4 事件订阅(React 模式)
useEffect(() => {
// 返回值是 unsubscribe 函数,可直接用于 cleanup
const unsub1 = client.on('message', (msg) => {
// msg: StoredMessage — 已解密的消息
setMessages(prev => [...prev, msg]);
});
const unsub2 = client.on('network_state', (state) => {
// state: 'connected' | 'connecting' | 'disconnected'
setNetworkState(state);
});
const unsub3 = client.on('goaway', (reason) => {
// 被其他设备踢下线 → 清除身份 → 跳转登录
client.disconnect();
clearIdentity();
navigateTo('welcome');
});
return () => { unsub1(); unsub2(); unsub3(); };
}, []);C.5 好友列表 + 分类
// syncFriends() 自动建立 ECDH 会话(不需要 App 层干预)
const friends = await client.contacts.syncFriends();
// HTTP 返回 snake_case 字段!
const pendingReceived = friends.filter(f => f.status === 'pending' && f.direction === 'received');
const pendingSent = friends.filter(f => f.status === 'pending' && f.direction === 'sent');
const accepted = friends.filter(f => f.status === 'accepted');
// 点击好友进入聊天 → 用 conversation_id(snake_case!)
setActiveChatId(friend.conversation_id);C.6 发消息 + 收消息
// 发文本
const msgId = await client.sendMessage(conversationId, toAliasId, 'Hello!');
// 发图片(SDK 自动:压缩 → 加密 → 上传 → 拼协议)
const imgId = await client.sendImage(conversationId, toAliasId, file);
// 撤回
await client.retractMessage(msgId, toAliasId, conversationId);
// 标记已读
client.markAsRead(conversationId, maxSeq, toAliasId);
// 加载历史(分页)
const history = await client.getHistory(conversationId, { limit: 50, before: oldestTimestamp });C.7 退出登录(顺序很重要)
import { clearIdentity } from '@daomessage_sdk/sdk';
client.disconnect(); // 1. 断开 WebSocket
await clearIdentity(); // 2. 清除 IndexedDB 身份
await client.clearAllHistory(); // 3. 清除消息历史
localStorage.clear(); // 4. 清除 App 层缓存
navigateTo('welcome');C.8 GOAWAY 多端踢出处理
// 必须监听!不处理会导致多设备冲突
useEffect(() => client.on('goaway', async (reason) => {
client.disconnect();
await clearIdentity();
await client.clearAllHistory();
localStorage.clear();
navigateTo('welcome');
}), []);C.9 强制双边密钥核对(铁律 11 配套实现)
三状态状态机:
unverified→my_side_verified→verified完整背景见 Developer Guide 的 Security 章节(doc.daomessage.com/en/guide/security)。 这里给出 AI 生成代码时必须遵循的接线模式。
C.9.1 计算双向核对码(用户在 modal 里看到的码)
import {
loadIdentity, deriveIdentity, loadSession, fromBase64,
computeSharedSecret, computeDirectionalCode, normalizeDirectionalCode,
computeSecurityCode, formatSecurityCode,
} from '@daomessage_sdk/sdk';
// modal 打开时计算
const stored = await loadIdentity();
const identity = deriveIdentity(stored!.mnemonic);
const myPriv = identity.ecdhKey.privateKey;
const myPub = identity.ecdhKey.publicKey;
const session = await loadSession(conversationId);
const theirPub = fromBase64(session!.theirEcdhPublicKey);
const shared = computeSharedSecret(myPriv, theirPub);
// "我的码"——给用户看 + 通过外部渠道(微信/邮件/短信)发给对方
const myCode = computeDirectionalCode(shared, myPub, theirPub);
// "对方应该发我的码"——本地比对用,不显示给用户
const expectedTheirCode = computeDirectionalCode(shared, theirPub, myPub);
// 高级模式:60 hex 完整公钥指纹(双方一致,给极客对照)
const fingerprint60 = formatSecurityCode(computeSecurityCode(myPub, theirPub));关键约束(不要踩):
- A 看到的
myCode≠ B 看到的myCode,这是故意的(方向性派生)。UI 必须告知用户"双方的码不一样是正常的"。- 比对必须用
normalizeDirectionalCode()归一化后再===,不能比较原始字符串(用户输入会带空格/横线/大小写)。
C.9.2 用户输入对方码 → 本地比对 → 标记我这边核对完
import { markMyVerified } from '@daomessage_sdk/sdk';
const ok = normalizeDirectionalCode(userInput)
=== normalizeDirectionalCode(expectedTheirCode);
if (!ok) {
// 显示错误提示。3 次失败 → 强警告(可能是 MITM 或复制错位)
return;
}
// 比对通过 → 调 SDK 标记
await markMyVerified(
'https://relay.daomessage.com',
await (client as any).http.getToken(), // 当前 JWT
friendshipId, // 从 session 或 friend 对象拿
conversationId,
);
// 此刻:本地 trustState = 'my_side_verified'
// 服务端 friendship.user_X_verified_at 已写
// 但还不能发消息——等对方也调 markMyVerifiedC.9.3 监听 trustState 变化 → 解锁聊天
// 拿到 MessageModule 内部实例(SDK 这一层未来会有官方 client.on('trust_state', ...) API)
const inner = (client as any).messages?.inner;
if (inner) {
inner.onTrustStateChange = async ({ conversationId: cId }) => {
const fresh = await loadSession(cId);
const newState = fresh?.trustState ?? 'unverified';
setTrustState(newState);
// 双方都完成核对 → 自动关闭 modal + 解锁聊天
if (newState === 'verified') {
setShowVerifyModal(false);
}
};
}C.9.4 三状态 UI 必须区分
import type { SessionTrustState } from '@daomessage_sdk/sdk';
const overlay = trustState === 'verified'
? null // 完全解锁,正常聊天 UI
: trustState === 'my_side_verified'
? <BlueOverlay text="You verified. Waiting for peer..." />
: <YellowOverlay text="Verify security code to unlock chat" actionButton="Start verification" />;| trustState | overlay | header 角标 | 输入栏 |
|---|---|---|---|
unverified | 黄色硬遮罩 + "开始核对"按钮 | ⚠ 未核验安全会话 | 禁用 |
my_side_verified | 蓝色软遮罩 + "等对方核对" + spinner | ✓ 等对方核对 | 禁用 |
verified | 无遮罩 | 绿色 ✓ 已核验 | 正常 |
C.9.5 modal 三状态视图(核心 UX)
modal 打开时根据当前 session.trustState 决定显示哪个视图,不要每次都从输入态开始(用户已经核对过的话,再开 modal 直接显示等待视图):
const view = session.trustState === 'my_side_verified' ? 'waiting' : 'input';view='input':显示我的码 + 输入对方码 + [确认核对] 按钮view='waiting':显示我的码(带"重新分享"按钮,应付对方说"没收到再发一次")+ "等待对方核对中" 提示 + [关闭] 按钮
markMyVerified 成功后不要立即关闭 modal,切换到 view='waiting',让用户明确感知"我这边已经完成了"。当 trustState 升到 verified 时(onTrustStateChange 回调中),父组件再自动关闭 modal。
C.9.6 重置核对(对方换设备 / 怀疑被攻击)
import { verifyReset } from '@daomessage_sdk/sdk';
await verifyReset(
'https://relay.daomessage.com',
await (client as any).http.getToken(),
friendshipId,
conversationId,
);
// 双方都降回 unverified,quarantine 队列**直接丢弃**(不回放,因为可能含攻击注入的伪造消息)C.9.7 send 失败处理
try {
await client.sendMessage(conversationId, toAliasId, text);
} catch (e: any) {
if (e?.kind === 'UNVERIFIED_SESSION' || /unverified/i.test(e?.message)) {
// SDK 拦截了:未核对不能发
showToast('Please verify the security code first');
setShowVerifyModal(true);
return;
}
// 其他错误...
}Part D: Web/React (TypeScript) 实现指南
一、工程初始化
npm create vite@latest my-chat-app -- --template react-ts
cd my-chat-app
npm install zustand framer-motion lucide-react clsx tailwind-merge @daomessage_sdk/sdk
npm install -D tailwindcss @tailwindcss/vite vite-plugin-pwasrc/lib/utils.ts
import clsx, { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }⚠️ AI 强制约定:React 编码红线(违反必出 Bug)
❌ 禁止:将子组件/辅助组件定义在父组件函数体内部
✅ 正确:所有可复用的组件必须定义在模块级别(组件函数外部)反面例子(禁止这样写):
function ContactsTab() {
// ❌ 错误:每次父组件渲染,SectionHeader 都是新函数引用
// React 会把其下的整个子树卸载重建,导致子节点 onClick 丢失
const SectionHeader = ({ title }) => <div>{title}</div>;
return <SectionHeader title="好友" />;
}正确写法:
// ✅ 正确:模块级定义,函数引用稳定,React diff 不会触发子树重建
function SectionHeader({ title }: { title: string }) {
return <div>{title}</div>;
}
function ContactsTab() {
return <SectionHeader title="好友" />;
}❌ 禁用非法嵌套(触发 Hydration Error):禁止将 <button> 嵌套在 <button> 内,或在 <p> 中使用 <div> 等块级元素。
✅ 正确:如果需要嵌套可点击区域,外层应使用具语义的 <div role="button" tabIndex={0} onClick={...}> 并在内部谨慎放置真实的 <button>。⚠️ 字体加载规范(违反会导致 OTS parsing error)
🔴 铁律:字体只能通过
index.html的<link>标签从 Google Fonts CDN 加载。 禁止:下载.woff2文件到本地 → 用@font-face引用(容易路径错误导致 OTS 解析失败)。 禁止:在 CSS 中引用不存在的本地字体文件。
index.html 必须包含:
<head>
<!-- Google Fonts CDN — 只用 link 标签,不要下载字体文件 -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>index.css(TailwindCSS v4):
@import "tailwindcss";
@theme {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
}如果不需要自定义字体,也可以完全省略字体配置,直接使用系统默认字体——千万不要引用一个不存在的字体文件。
1.5 PWA 配置(manifest + Service Worker + 安装引导)
PWA 是本项目的核心交付形态。 用户通过浏览器访问后,必须能"安装到主屏"成为独立 App。 缺少 PWA 配置 = 用户无法离线使用、无法收推送、无法安装。
vite.config.ts — PWA 插件配置
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { VitePWA } from 'vite-plugin-pwa';
import path from 'path';
export default defineConfig({
// ⚠️ 路径别名 — 禁止使用 ../../ 相对路径,统一用 @/ 开头
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['pwa-192x192.png', 'pwa-512x512.png'],
manifest: {
name: 'DAO MESSAGE',
short_name: 'DAO MSG',
description: 'Zero-Knowledge Encrypted Messaging',
theme_color: '#09090b',
background_color: '#09090b',
display: 'standalone',
start_url: '/',
icons: [
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
},
}),
],
});tsconfig.json — 路径别名(与 vite.config.ts 保持一致):
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}🔴 铁律:所有 import 必须使用
@/开头的绝对路径,禁止../../相对路径。typescript// ❌ 禁止:import { client } from '../../lib/imClient' // ✅ 正确:import { client } from '@/lib/imClient'
⚠️ 图标必须存在:在
public/目录放置pwa-192x192.png和pwa-512x512.png。 没有图标 = manifest 校验失败 = 不触发安装提示。可以使用任意纯色图标占位。
Service Worker 推送监听 src/sw.ts
如果使用
vite-plugin-pwa的generateSW模式,以下代码由插件自动注入。 如果使用injectManifest模式,需要手动创建此文件。
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
// 推送通知(零知识:仅展示通用提示,不泄露聊天内容)
self.addEventListener('push', (event) => {
const title = 'New Message';
const options: NotificationOptions = {
body: 'You have a new message',
icon: '/pwa-192x192.png',
badge: '/pwa-192x192.png',
data: {},
};
event.waitUntil(self.registration.showNotification(title, options));
});
// 点击通知:唤醒/聚焦 App(不含 conversationId,零知识)
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
const existing = clients.find((c) => c.url.includes(self.location.origin));
if (existing) return existing.focus();
return self.clients.openWindow('/');
})
);
});InstallBanner.tsx — 安装引导组件
👤 App 实现 — 监听
beforeinstallprompt事件 + iOS Safari 手动引导
import { useState, useEffect } from 'react';
import { Download, X } from 'lucide-react';
// ⚠️ 必须定义在模块级,不可定义在组件内部
let deferredPrompt: any = null;
export function InstallBanner() {
const [showBanner, setShowBanner] = useState(false);
const [isIOS, setIsIOS] = useState(false);
// 检测是否已安装为 PWA
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|| (navigator as any).standalone === true;
useEffect(() => {
if (isStandalone) return; // 已安装,不显示
// iOS Safari 检测(不支持 beforeinstallprompt)
const ua = navigator.userAgent;
if (/iPhone|iPad|iPod/.test(ua) && /Safari/.test(ua) && !/CriOS|FxiOS/.test(ua)) {
setIsIOS(true);
setShowBanner(true);
return;
}
// Android / Desktop Chrome
const handler = (e: Event) => {
e.preventDefault();
deferredPrompt = e;
setShowBanner(true);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
if (!showBanner) return null;
const handleInstall = async () => {
if (isIOS) return; // iOS 需手动操作
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') setShowBanner(false);
deferredPrompt = null;
};
return (
<div className="fixed bottom-20 left-4 right-4 z-50 bg-zinc-800 border border-zinc-700 rounded-2xl p-4 flex items-center gap-3 shadow-2xl">
<Download className="w-6 h-6 text-blue-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-zinc-100">Install App</p>
<p className="text-xs text-zinc-400 truncate">
{isIOS
? 'Tap Share ⎙ → "Add to Home Screen"'
: 'Install for offline access & notifications'}
</p>
</div>
{!isIOS && (
<button onClick={handleInstall}
className="px-4 py-2 bg-blue-500 text-white text-sm rounded-xl font-medium shrink-0">
Install
</button>
)}
<button onClick={() => setShowBanner(false)} className="p-1 text-zinc-500">
<X className="w-4 h-4" />
</button>
</div>
);
}👤 App:在
MainLayout.tsx底部渲染<InstallBanner />(Tab 栏之上)
二、全局状态 src/store/appStore.ts
👤 App 实现 — SDK 不管 UI 状态,全部在 Zustand 管理
import { create } from 'zustand';
export type AppRoute =
| 'welcome' | 'generate_mnemonic' | 'confirm_backup'
| 'vanity_shop' | 'set_nickname' | 'recover' | 'main';
export type MainTab = 'messages' | 'channels' | 'contacts' | 'settings';
interface AppState {
route: AppRoute;
setRoute: (route: AppRoute) => void;
// 注册流程中临时暂存助记词(让用户手抄后再提交)
// ⚠️ 不要存到 localStorage,只活在内存里
tempMnemonic: string;
setTempMnemonic: (m: string) => void;
sdkReady: boolean;
setSdkReady: (ready: boolean) => void;
// 仅存 UI 展示用的用户信息
// ⚠️ userId 不用自己管理,aliasId 从 registerAccount 返回值拿
userId: string;
aliasId: string;
nickname: string;
setUserInfo: (id: string, aliasId: string, name: string) => void;
activeTab: MainTab;
setActiveTab: (tab: MainTab) => void;
activeChatId: string | null; // conversationId(由 SDK acceptFriendRequest → conversation_id 得到)
setActiveChatId: (id: string | null) => void;
pendingRequestCount: number; // 通讯录 Tab 红点角标
setPendingRequestCount: (count: number) => void;
activeChannelId: string | null;
setActiveChannelId: (id: string | null) => void;
// V1.1 新增:未读消息计数(按会话 ID 维护)
unreadCounts: Record<string, number>;
incrementUnread: (convId: string) => void;
clearUnread: (convId: string) => void;
}
export const useAppStore = create<AppState>((set) => ({
route: 'welcome',
setRoute: (route) => set({ route }),
tempMnemonic: '',
setTempMnemonic: (tempMnemonic) => set({ tempMnemonic }),
sdkReady: false,
setSdkReady: (sdkReady) => set({ sdkReady }),
userId: '', aliasId: '', nickname: '',
setUserInfo: (userId, aliasId, nickname) => set({ userId, aliasId, nickname }),
activeTab: 'messages',
setActiveTab: (activeTab) => set({ activeTab }),
activeChatId: null,
setActiveChatId: (activeChatId) => set({ activeChatId }),
pendingRequestCount: 0,
setPendingRequestCount: (pendingRequestCount) => set({ pendingRequestCount }),
activeChannelId: null,
setActiveChannelId: (activeChannelId) => set({ activeChannelId }),
// V1.1 新增:未读计数
unreadCounts: {},
incrementUnread: (convId) => set((state) => ({
unreadCounts: { ...state.unreadCounts, [convId]: (state.unreadCounts[convId] || 0) + 1 }
})),
clearUnread: (convId) => set((state) => {
const next = { ...state.unreadCounts };
delete next[convId];
return { unreadCounts: next };
}),
}));
/** 全局未读消息总量(在组件中使用 useAppStore(selectTotalUnread)) */
export const selectTotalUnread = (state: AppState): number =>
Object.values(state.unreadCounts).reduce((sum, n) => sum + n, 0);三、SDK 单例桥 src/lib/imClient.ts
👤 App 实现 — 解决 SDK 单例与 React 多组件之间的事件分发问题
🔒 SDK 自动:连接、重连、心跳、鉴权、加解密(new SecureChatClient()无参数,API 地址硬编码在 SDK 内)
import { SecureChatClient, NetworkState as NS, StoredMessage, TypingEvent } from '@daomessage_sdk/sdk';
export type NetworkState = NS;
// 🔒 SDK:无参实例化,CORE_API_BASE 已硬编码在 SDK 内(不可传参、不可覆盖)
export const client = new SecureChatClient();
export function initIMClient() {
// connect() 是 async(先获取一次性 ticket 再建连 WS,JWT 不暴露在 URL 中)
client.connect().catch(err => console.warn('[WS] connect error:', err));
}
// ── 事件总线(Set 结构,React 组件 useEffect 里 add/delete 安全绑定/解绑)──
// 👤 App 实现:将 SDK 单例事件广播到所有监听组件
// ⚠️ SDK on() 现已返回 unsubscribe 函数,新组件可直接用:
// useEffect(() => client.on('message', handler), []) // 自动解绑
// 以下 Set 结构为历史兼容,保留给全局广播层使用
export const localMessageHandlers = new Set<(msg: StoredMessage) => void>();
export const localStatusHandlers = new Set<(status: { id: string; status: string }) => void>();
export const networkListeners = new Set<(state: NetworkState) => void>();
export const localChannelPostHandlers = new Set<(data: any) => void>();
export const localTypingHandlers = new Set<(data: TypingEvent) => void>(); // 新增
// 🔒 SDK:触发事件(已解密的 StoredMessage / status / 网络状态 / typing)
// 👤 App:用 Set 分发给所有订阅组件
// V1.1 新增:收到新消息时,若不在当前会话则自增未读计数
client.on('message', (msg) => {
const { activeChatId, incrementUnread } = useAppStore.getState();
if (msg.conversationId !== activeChatId) {
incrementUnread(msg.conversationId);
}
localMessageHandlers.forEach(h => h(msg));
});
client.on('status_change',(status) => localStatusHandlers.forEach(h => h(status)));
client.on('network_state',(state) => networkListeners.forEach(h => h(state)));
client.on('channel_post', (data) => localChannelPostHandlers.forEach(h => h(data)));
client.on('typing', (data) => localTypingHandlers.forEach(h => h(data)));
/** NetworkBanner 专用辅助函数,返回取消订阅函数 */
export function onNetworkStateChange(fn: (state: NetworkState) => void) {
networkListeners.add(fn);
return () => { networkListeners.delete(fn); };
}四、冷启动入口 src/App.tsx
责任说明:
🔒 SDK:restoreSession()— 读 IndexedDB → Ed25519 签名挑战 → 获取 JWT Token
👤 App:拿到返回值后更新 Zustand store,处理 URL Deep Link,调用initIMClient()
import { useEffect } from 'react';
import { useAppStore } from '@/store/appStore';
import { Welcome } from '@/components/onboarding/Welcome';
import { GenerateMnemonic } from '@/components/onboarding/GenerateMnemonic';
import { SetNickname } from '@/components/onboarding/SetNickname';
import { Recover } from '@/components/onboarding/Recover';
import { MainLayout } from '@/components/main/MainLayout';
import { ChatWindow } from '@/components/chat/ChatWindow';
import { initIMClient, client } from '@/lib/imClient';
function App() {
const { route, activeChatId, setRoute, setSdkReady, setUserInfo, setActiveChatId } = useAppStore();
// ① 👤 App:监听 ServiceWorker 消息(PWA 后台被通知点击唤醒)
// 🔒 SDK 不管 PWA 生命周期,由业务层监听 navigator.serviceWorker
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'OPEN_CHAT' && event.data.conversationId) {
if (event.data.conversationId !== 'default') setActiveChatId(event.data.conversationId);
setRoute('main');
}
};
navigator.serviceWorker?.addEventListener('message', handleMessage);
return () => navigator.serviceWorker?.removeEventListener('message', handleMessage);
}, [setActiveChatId, setRoute]);
// ② 冷启动检测(🚨 强制必需 active 标志位防止 React 18 严格模式导致并发 Restore 和 WebSocket 抢占/被踢 🚨)
useEffect(() => {
let active = true; // ← 防护竞态条件
// 🔒 SDK 内部:IndexedDB 读身份 → Challenge 签名 → 获取 JWT → 返回 { aliasId }
client.restoreSession().then(session => {
if (!active) return; // ← 抛弃已被 react 卸载的过时 Promise 触发
if (session) {
// 👤 App:处理 URL Deep Link(点击推送通知冷启动)
const params = new URLSearchParams(window.location.search);
const chatId = params.get('chat');
// 👤 App:更新 UI 状态
// ✅ SDK restoreSession 现已返回 nickname,不再需要从 localStorage 读取
const nickname = session.nickname || localStorage.getItem('sc_nickname') || 'Me';
setUserInfo('', session.aliasId, nickname);
// 👤 App → 触发 SDK:建立 WebSocket 连接
// initIMClient() 内部 fire-and-forget,已自带 .catch()
initIMClient();
if (chatId && chatId !== 'default') {
setActiveChatId(chatId);
window.history.replaceState({}, document.title, window.location.pathname);
}
// 👤 App:静默恢复推送(仅在已授权时,不触发系统弹窗)
// 🔒 SDK:enablePushNotifications 内部完成凭证订阅+上传服务端
if ('Notification' in window && Notification.permission === 'granted') {
navigator.serviceWorker?.ready.then(reg =>
client.push.enablePushNotifications(reg).catch(console.warn)
).catch(console.warn);
}
setSdkReady(true);
setRoute('main');
} else {
setRoute('welcome');
}
}).catch(() => {
// 没有本地身份,停留在 welcome 页
if (active) setRoute('welcome');
});
return () => { active = false; };
}, [setUserInfo, setRoute, setActiveChatId, setSdkReady]);
switch (route) {
case 'welcome': return <Welcome />;
case 'generate_mnemonic': return <GenerateMnemonic />;
case 'confirm_backup': return <ConfirmBackup />; {/* 助记词确认备份页 */}
case 'vanity_shop': return <VanityShop />; {/* 注册后靓号选号页(可跳过)*/}
case 'set_nickname': return <SetNickname />;
case 'recover': return <Recover />;
case 'main':
return (
<div className="min-h-screen bg-zinc-950 text-zinc-50">
{activeChatId ? <ChatWindow /> : <MainLayout />}
</div>
);
default: return <Welcome />;
}
}
export default App;四.5 GOAWAY 多端踢出处理(V1.1 新增)
🔒 SDK:transport 收到
{type:'goaway'}帧时 emit'goaway'事件
👤 App:监听事件 → 弹出全屏警告 → 用户确认后清除本地身份
// App.tsx — 顶层 useEffect
useEffect(() => {
// 🔒 SDK:WebSocket 收到服务端 GOAWAY 帧(另一台设备登录,旧连接被踢)
// 👤 App:弹出全屏遮罩,不可忽略
return client.on('goaway', (_reason) => {
setGoawayVisible(true);
});
}, []);
// 👤 App:全屏弹窗 UI(fixed z-[200] 覆盖一切)
if (goawayVisible) {
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80">
<div className="bg-zinc-900 rounded-2xl p-8 max-w-sm text-center space-y-4">
<h2 className="text-lg font-bold text-red-400">⚠️ 账号已在其他设备登录</h2>
<p className="text-sm text-zinc-400">
您的账号已在另一台设备上登录。为保护安全,当前设备已断开连接。
</p>
<button className="w-full py-3 bg-zinc-800 rounded-xl text-zinc-200"
onClick={async () => {
// 👤 App → SDK:断开 + 清除身份
// ⚠️ clearAllMessages 未从 @daomessage_sdk/sdk 导出,必须用 client.clearAllHistory()
// clearIdentity 已在顶部静态导入,无需动态 import
client.disconnect();
await client.clearAllHistory(); // 🔒 SDK:清 IndexedDB messages + sessions
await clearIdentity(); // 🔒 SDK(静态导入):清 IndexedDB identity
localStorage.clear();
setGoawayVisible(false);
setRoute('welcome');
}}>确定</button>
</div>
</div>
);
}五、Onboarding 流程(责任边界逐步拆解)
Welcome.tsx — 入口
import { newMnemonic } from '@daomessage_sdk/sdk';
// 🔒 SDK:newMnemonic() 是同步函数(基于 BIP-39 词库),无需 await
// ❌ 错误写法:const m = await newMnemonic()
// ✅ 正确写法:const m = newMnemonic()
const handleStart = () => {
const m = newMnemonic(); // 🔒 SDK:生成 12 词
setTempMnemonic(m); // 👤 App:暂存到 Zustand(不存 localStorage!)
setRoute('generate_mnemonic'); // 👤 App:路由跳转
};
const handleRecover = () => setRoute('recover'); // 👤 AppGenerateMnemonic.tsx — 展示并备份助记词
// 👤 App:展示 + 备份交互
// 助记词已在 Welcome 生成并存入 Zustand store,这里直接读 tempMnemonic
const words = tempMnemonic.split(' '); // string[12],UI 用 grid 3列展示
// 需要:一键复制按钮 + 勾选框"我已安全备份"(两者都满足才能点"下一步")
// 点"下一步" → setRoute('set_nickname')(不要在这一步调任何 SDK 方法)Recover.tsx — 用助记词恢复账户
import { validateMnemonicWords } from '@daomessage_sdk/sdk';
// 🔒 SDK:validateMnemonicWords 校验 BIP-39 词库合法性(同步,返回 boolean)
const handleRecover = () => {
const m = input.trim();
const words = m.split(' ');
if (words.length < 12) { setError('请输入 12 个单词'); return; }
// 🔒 SDK:格式校验
if (!validateMnemonicWords(m)) { setError('助记词无效,请检查拼写'); return; }
setTempMnemonic(m); // 👤 App:暂存
setRoute('set_nickname'); // 👤 App:复用注册进行恢复,逻辑相同
};SetNickname.tsx — 注册全链路(最关键)
🔴 铁律:
- 禁止在
registerAccount之前调用clearIdentity()或任何清理 IndexedDB 的函数——这会关闭数据库连接导致InvalidStateError。- 必须使用静态 import,不要用
await import()动态导入 imClient——client 是全局单例,App.tsx 已静态导入。- 必须 try-catch 整个 handleComplete——注册是网络操作,可能失败。
// ⚠️ 必须静态 import,不要 await import() 动态导入!
import { client, initIMClient } from '@/lib/imClient';
import { useAppStore } from '@/store/appStore';
const handleComplete = async () => {
if (!nickname.trim()) return;
try {
// ──── 🔒 SDK 全程自动完成(以下所有步骤无需 App 感知)────
// 1. 从助记词派生 Ed25519 签名密钥 + X25519 ECDH 密钥
// 2. 计算 PoW(工作量证明,防刷注册)
// 3. POST /api/v1/register(公钥上传)
// 4. GET /api/v1/auth/challenge → Ed25519 签名 → POST /api/v1/auth/verify
// 5. 获取 JWT Token,存入 HTTP Client 内部
// 6. 将身份(mnemonic + 密钥对 + 昵称)存入 IndexedDB
// 返回值仅有 { aliasId }
const { aliasId } = await client.auth.registerAccount(tempMnemonic, nickname.trim());
// ──── SDK 完成 ────
// 👤 App:SDK 不管昵称持久化到 localStorage,由 App 存
localStorage.setItem('sc_alias_id', aliasId);
localStorage.setItem('sc_nickname', nickname.trim());
setUserInfo('', aliasId, nickname.trim()); // 👤 App:更新 Zustand
// 👤 App → SDK:建立 WebSocket
initIMClient();
// 👤 App:请求推送权限(浏览器原生 API),然后由 SDK 完成凭证注册
navigator.serviceWorker?.ready.then(reg =>
// 🔒 SDK:订阅 Web Push + POST /api/v1/push/register
client.push.enablePushNotifications(reg).catch(console.warn)
);
setSdkReady(true);
// 👤 App:注册完成后进入靓号商店(用户可跳过)
// 🔴 靓号仅在此 onboarding 阶段可选购,注册完成后不再提供购买入口
setRoute('vanity_shop');
} catch (error: any) {
// 👤 App:必须展示错误信息给用户,禁止空 catch
console.error('[SetNickname] Registration failed:', error);
setError(error.message || 'Registration failed. Please try again.');
}
};5.5 VanityShop.tsx — 靓号商店(仅 Onboarding 阶段)
🔴 铁律:靓号仅在注册后的 onboarding 流程中可购买,一旦进入主界面后不再提供任何购买入口。 alias_id 一旦绑定终身不可更改。用户可跳过此步骤,使用系统分配的默认 ID。
SDK API 类型(摘自 @daomessage_sdk/sdk)
// VanityItem — 搜索结果项
interface VanityItem {
alias_id: string // 靓号 ID(如 "88888888")
price_usdt: number // 价格(USDT)
tier: string // 'top' | 'premium' | 'standard'
is_featured: boolean // 是否精选推荐
}
// PurchaseOrder — 购买下单返回
interface PurchaseOrder {
order_id: string
price_usdt: number
pay_to: string // TRON 区块链收款地址
expired_at: string // ISO 8601 过期时间
}
// OrderStatus — 轮询状态返回
interface OrderStatus {
status: 'PENDING' | 'CONFIRMING' | 'COMPLETED' | 'EXPIRED'
}完整组件代码
import { useState, useEffect, useRef } from 'react';
import { Search, Star, Loader2, Copy, Check, ArrowRight } from 'lucide-react';
import { client } from '@/lib/imClient';
import { useAppStore } from '@/store/appStore';
export function VanityShop() {
const { setRoute } = useAppStore();
const [query, setQuery] = useState('');
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selectedItem, setSelectedItem] = useState<any | null>(null);
const [order, setOrder] = useState<any | null>(null);
const [orderStatus, setOrderStatus] = useState<string>('');
const [copied, setCopied] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 🔒 SDK:搜索靓号(公开 API,无需 JWT)
const handleSearch = async (q: string) => {
setLoading(true);
try {
const results = await client.vanity.search(q || undefined);
setItems(results);
} catch (e: any) {
console.error('[VanityShop] search error:', e);
} finally {
setLoading(false);
}
};
// 首次加载精选靓号
useEffect(() => { handleSearch(''); }, []);
// 400ms 防抖搜索
useEffect(() => {
const timer = setTimeout(() => handleSearch(query), 400);
return () => clearTimeout(timer);
}, [query]);
// 🔒 SDK:购买下单
const handlePurchase = async (item: any) => {
try {
const purchaseOrder = await client.vanity.purchase(item.alias_id);
setOrder(purchaseOrder);
setOrderStatus('PENDING');
startPolling(purchaseOrder.order_id);
} catch (e: any) {
alert(`Purchase failed: ${e.message}`);
}
};
// 轮询支付状态
const startPolling = (orderId: string) => {
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
try {
// 🔒 SDK:查询订单状态
const status = await client.vanity.getOrderStatus(orderId);
setOrderStatus(status.status);
if (status.status === 'COMPLETED') {
clearInterval(pollRef.current!);
// 🔒 SDK:绑定靓号到当前用户
const { alias_id } = await client.vanity.bind(orderId);
localStorage.setItem('sc_alias_id', alias_id);
useAppStore.getState().setUserInfo('', alias_id, useAppStore.getState().nickname);
alert(`✅ Vanity ID ${alias_id} bound successfully!`);
setRoute('main');
} else if (status.status === 'EXPIRED') {
clearInterval(pollRef.current!);
alert('⏰ Order expired. Please try again.');
setOrder(null);
}
} catch (e) {
console.error('[VanityShop] poll error:', e);
}
}, 5000);
};
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);
// ── 支付弹窗 ──
if (order && orderStatus !== 'COMPLETED') {
return (
<div className="min-h-screen bg-zinc-950 text-white flex flex-col items-center justify-center p-6">
<div className="bg-zinc-900 rounded-2xl p-6 max-w-sm w-full space-y-4">
<h2 className="text-lg font-bold text-center">💎 Complete Payment</h2>
<p className="text-sm text-zinc-400 text-center">Send exactly the amount below to the TRON address</p>
<div className="bg-zinc-800 rounded-xl p-4 space-y-3">
<div className="flex justify-between">
<span className="text-zinc-500 text-sm">Amount</span>
<span className="text-white font-mono font-bold">{order.price_usdt} USDT</span>
</div>
<div>
<span className="text-zinc-500 text-sm">TRON Address</span>
<div className="flex items-center gap-2 mt-1">
<code className="text-xs text-blue-400 break-all flex-1">{order.pay_to}</code>
<button onClick={() => { navigator.clipboard.writeText(order.pay_to); setCopied(true); setTimeout(() => setCopied(false), 2000); }}
className="p-1.5 bg-zinc-700 rounded">
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5 text-zinc-300" />}
</button>
</div>
</div>
</div>
<div className="flex items-center justify-center gap-2 text-sm">
<Loader2 className="w-4 h-4 animate-spin text-yellow-400" />
<span className="text-zinc-400">
{orderStatus === 'CONFIRMING' ? 'Confirming on blockchain...' : 'Waiting for payment...'}
</span>
</div>
<button onClick={() => { if (pollRef.current) clearInterval(pollRef.current); setOrder(null); }}
className="w-full py-2 bg-zinc-800 rounded-xl text-zinc-400 text-sm">Cancel</button>
</div>
</div>
);
}
// ── 主界面 ──
return (
<div className="min-h-screen bg-zinc-950 text-white flex flex-col">
<div className="p-6 space-y-4 flex-1">
<h1 className="text-xl font-bold">💎 Choose Your Vanity ID</h1>
<p className="text-sm text-zinc-400">Pick a memorable ID or skip to use your default one.</p>
{/* 搜索框 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input value={query} onChange={e => setQuery(e.target.value)}
placeholder="Search numbers (e.g. 888, 666)"
className="w-full pl-10 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-sm text-white placeholder-zinc-500 focus:border-blue-500 outline-none" />
</div>
{/* 搜索结果 */}
{loading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin text-zinc-500" /></div>
) : (
<div className="space-y-2">
{items.map(item => (
<div key={item.alias_id}
className="flex items-center justify-between p-4 bg-zinc-900 rounded-xl border border-zinc-800 hover:border-zinc-600 transition">
<div className="flex items-center gap-3">
{item.is_featured && <Star className="w-4 h-4 text-yellow-400" />}
<span className="font-mono font-bold text-lg">{item.alias_id}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${
item.tier === 'top' ? 'bg-yellow-500/20 text-yellow-300' :
item.tier === 'premium' ? 'bg-purple-500/20 text-purple-300' :
'bg-zinc-700 text-zinc-300'
}`}>{item.tier}</span>
</div>
<button onClick={() => handlePurchase(item)}
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white text-sm rounded-xl font-medium transition">
{item.price_usdt} USDT
</button>
</div>
))}
{items.length === 0 && !loading && (
<p className="text-center text-zinc-500 py-8">No vanity IDs found</p>
)}
</div>
)}
</div>
{/* 跳过按钮 */}
<div className="p-6 pt-0">
<button onClick={() => setRoute('main')}
className="w-full py-3 bg-zinc-800 hover:bg-zinc-700 rounded-xl text-zinc-300 text-sm flex items-center justify-center gap-2 transition">
Skip for now <ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
);
}⚠️ App.tsx 路由表中必须包含
case 'vanity_shop': return <VanityShop />;⚠️SetNickname.tsx注册完成后跳转到setRoute('vanity_shop')而非setRoute('main')
六、MainLayout.tsx
export function MainLayout() {
const { activeTab, setActiveTab, pendingRequestCount, activeChannelId } = useAppStore();
// 频道详情全屏覆盖(优先渲染)
if (activeChannelId) return <ChannelDetail />;
return (
<div className="flex flex-col h-screen bg-zinc-950 text-white">
<NetworkBanner />
<div className="flex-1 overflow-y-auto">
{activeTab === 'messages' && <MessagesTab />}
{activeTab === 'channels' && <ChannelsTab />}
{activeTab === 'contacts' && <ContactsTab />}
{activeTab === 'settings' && <SettingsTab />}
</div>
{/* 底部 Tab 栏,通讯录有红点角标 */}
<BottomNav activeTab={activeTab} onTabChange={setActiveTab} badgeCount={pendingRequestCount} />
</div>
);
}七、NetworkBanner.tsx — 三色网络状态
import { onNetworkStateChange, NetworkState } from '@/lib/imClient';
export function NetworkBanner() {
const [state, setState] = useState<NetworkState>('connected');
const [showRecovered, setShowRecovered] = useState(false);
useEffect(() => {
// 👤 App:订阅事件总线
// 🔒 SDK:内部 WebSocket 状态变化时 emit 'network_state'
const unsub = onNetworkStateChange((newState) => {
setState(prev => {
if (prev !== 'connected' && newState === 'connected') {
setShowRecovered(true); // 断线恢复时短暂显示绿色横幅
setTimeout(() => setShowRecovered(false), 2000);
}
return newState;
});
});
return unsub;
}, []);
if (state === 'connected' && !showRecovered) return null;
// 渲染规则:
// showRecovered → 绿色 "连接已恢复"
// state=disconnected → 红色 "网络连接已断开"
// state=connecting → 黄色 "正在重新连接..." + Loader2 spin
}八、MessagesTab.tsx — 消息列表
🔒 SDK:
listSessions()读 IndexedDB sessions store;client.getHistory()读消息记录
👤 App:服务端补齐(漫游)、排序、时间格式化、预览格式化、PWA横幅检测
import { listSessions, SessionRecord, StoredMessage } from '@daomessage_sdk/sdk';
const loadSessionsWithPreviews = async () => {
// 🔒 SDK:读 IndexedDB sessions store
const rawSessions = await listSessions();
// 👤 App(可选):漫游补齐 — 与服务端 active 会话对比
const serverData = await client.http.get<{conversations: {conv_id: string}[]}>('/api/v1/conversations/active');
const localIds = new Set(rawSessions.map(s => s.conversationId));
serverData.conversations?.forEach(sc => {
if (!localIds.has(sc.conv_id)) console.log('[MessagesTab] 待同步会话:', sc.conv_id);
});
// 🔒 SDK:读每个会话的最后一条消息(IndexedDB messages store)
const withPreviews = await Promise.all(rawSessions.map(async s => {
const history = await client.getHistory(s.conversationId);
return { ...s, lastMessage: history[history.length - 1] };
}));
// 👤 App:按时间倒序排列
withPreviews.sort((a, b) =>
(b.lastMessage?.time || b.createdAt || 0) - (a.lastMessage?.time || a.createdAt || 0)
);
setSessions(withPreviews);
};
// 收到新消息时刷新列表(等 SDK 写完 IndexedDB 再读)
// 🔒 SDK:消息已在触发 on('message') 之前写入 IndexedDB
// 👤 App:延迟 200ms 重新 listSessions
localMessageHandlers.add(() => setTimeout(loadSessionsWithPreviews, 200));
// 👤 App:消息预览格式化
const renderPreview = (text?: string) => {
if (!text) return '[图片]';
try { if (JSON.parse(text).type === 'image') return '[图片]'; } catch {}
return text;
};
// 👤 App:时间格式化(今天=时分、昨天="昨天"、7天内="周X"、更早="月/日")
const formatTime = (ts: number) => { ... };
// 👤 App:PWA 检测(非 standalone 显示"浏览器模式"提示条)
const isPWA = window.matchMedia('(display-mode: standalone)').matches;
// 🔒 SDK → SessionRecord.trustState:
// 'verified' → 绿色 ShieldCheck 角标
// 'unverified' → 黄色 ShieldAlert + "安全会话未核对..."8.1 未读角标(V1.1 新增)
// 👤 App:每个会话条目显示未读计数
const { unreadCounts, clearUnread } = useAppStore();
// 会话列表渲染时:
const unread = unreadCounts[s.conversationId] || 0;
// → unread > 0 时显示红色角标(99+ 封顶)
// → 未读会话的昵称和预览文字加粗 (font-medium text-white)
// 点击进入会话时重置未读:
onClick={() => {
clearUnread(s.conversationId);
setActiveChatId(s.conversationId);
}}
// Tab 底栏计数(在 MainLayout.tsx 中):
import { selectTotalUnread } from '@/store/appStore';
const totalUnread = useAppStore(selectTotalUnread);
// → 传给 💬 图标的 badge 属性8.2 清除单个会话(V1.1 新增)
🔒 SDK:
client.clearHistory(convId)清除消息 +deleteSession(convId)删除会话记录
👤 App:长按/右键菜单 + 确认弹窗
import { deleteSession } from '@daomessage_sdk/sdk';
// 👤 App:会话列表长按菜单
const [deleteTarget, setDeleteTarget] = useState<SessionRecord | null>(null);
// 触发:
onContextMenu={(e) => {
e.preventDefault();
setDeleteTarget(session);
}}
// 确认弹窗:
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="bg-zinc-900 rounded-2xl p-6 max-w-xs space-y-4">
<p className="text-sm text-zinc-200">
确定删除与 {deleteTarget.theirAliasId} 的聊天记录?
</p>
<div className="flex gap-3">
<button onClick={() => setDeleteTarget(null)}
className="flex-1 py-2 bg-zinc-800 rounded-xl text-zinc-400">取消</button>
<button onClick={async () => {
// 🔒 SDK:清除 IndexedDB 消息 + 会话
await client.clearHistory(deleteTarget.conversationId);
await deleteSession(deleteTarget.conversationId);
clearUnread(deleteTarget.conversationId);
setDeleteTarget(null);
loadSessionsWithPreviews(); // 刷新列表
}} className="flex-1 py-2 bg-red-500/20 text-red-400 rounded-xl">删除</button>
</div>
</div>
</div>
)}九、ContactsTab.tsx — 通讯录
🔒 SDK 做的:
syncFriends()拉取好友列表 + 自动为 accepted 好友建立本地 ECDH 会话(写 IndexedDB)
👤 App 做的:UI 三分类展示、10 秒轮询更新红点角标
SDK 精确类型定义(摘自 @daomessage_sdk/sdk 源码,AI 必须严格按此字段名编写)
// 来自 sdk-typescript/src/contacts/manager.ts
// 对外导出为 ContactProfile(import type { ContactProfile } from '@daomessage_sdk/sdk')
interface FriendProfile { // ← 导出别名 ContactProfile
friendship_id: number // ← number!不是 string
alias_id: string
nickname: string
status: 'pending' | 'accepted' | 'rejected'
direction: 'sent' | 'received'
conversation_id: string
x25519_public_key: string
ed25519_public_key: string // ← 对方的签名公钥
created_at: string
}
// lookupUser 精确返回类型
// import type { ContactProfile } from '@daomessage_sdk/sdk' — ContactProfile 即 FriendProfile
type LookupResult = { alias_id: string; nickname: string; x25519_public_key: string; ed25519_public_key: string }
// 用法:const user = await client.contacts.lookupUser(aliasId)
// user.alias_id ✅ user.aliasId ❌// 🔒 SDK:syncFriends() 内部:GET /friends → 为 accepted 好友自动 establishSession(ECDH) const list = await client.contacts.syncFriends();
// 👤 App:三分类 — ⚠️ 三个分区必须全部渲染,禁止省略或注释掉任何一个 const pendingReceived = list.filter(f => f.status === 'pending' && f.direction === 'received'); const pendingSent = list.filter(f => f.status === 'pending' && f.direction === 'sent'); const acceptedFriends = list.filter(f => f.status === 'accepted');
// 👤 App:更新红点角标 setPendingRequestCount(pendingReceived.length);
// 👤 App:10 秒轮询(没有 WebSocket 推送好友请求通知,靠轮询) useEffect(() => { loadData(); const timer = setInterval(loadData, 10000); return () => clearInterval(timer); }, []);
// 二步添加好友:① 查找 → ② 确认 // 🔒 SDK:lookupUser() → GET /users/{aliasId}(返回公钥等信息) const user = await client.contacts.lookupUser(addId.trim());
// 🔒 SDK:sendFriendRequest() → POST /friends/request // ⚠️ 409 Conflict 表示好友关系已存在(pending 或 accepted),App 必须 catch 并给出友好提示, // 禁止使用空 catch {} 静默吞掉错误! try { await client.contacts.sendFriendRequest(addId.trim()); } catch (e) { // 必须区分 409 和其他错误,409 时提示"请求已存在,请等待对方确认" if (e.message?.includes('409')) { showToast('好友请求已存在,请查看「等待确认」分区'); } else { showToast(添加失败: ${e.message}); } }
// 🔒 SDK:acceptFriendRequest(friendshipId) → // PUT /friends/{id}/accept → 获取 conversation_id // + ECDH 密钥交换 → 写 IndexedDB sessions store // ⚠️ friendship_id 是 number 类型! await client.contacts.acceptFriendRequest(req.friendship_id); // number
// 👤 App:点击已接受好友 → 进入聊天 // conversation_id 从 FriendProfile.conversation_id 获取(SDK syncFriends 返回,导出名 ContactProfile) setActiveChatId(friend.conversation_id); setActiveTab('messages');
### ⚠️ ContactsTab UI 强制渲染规则
> **AI 必须遵守**:以下三个分区必须全部在 UI 中渲染,不可省略、注释或合并。
| 分区 | 数据源 | UI 要求 |
|------|--------|---------|
| **📨 收到的好友请求** | `pendingReceived` | 显示对方昵称 + alias_id,提供「接受」按钮 |
| **⏳ 等待对方确认** | `pendingSent` | 显示对方昵称 + alias_id,标记「等待中」状态,**不可省略此分区** |
| **👥 我的好友** | `acceptedFriends` | 显示已建立的好友,点击进入聊天 |
### ⚠️ 错误处理强制规则
| API 调用 | 可能的错误 | 必须的 UI 行为 |
|----------|-----------|---------------|
| `sendFriendRequest()` | **409** — 好友关系已存在 | 显示提示"请求已存在,请查看等待确认列表",**禁止空 catch** |
| `sendFriendRequest()` | **404** — 用户不存在 | 显示提示"用户不存在" |
| `acceptFriendRequest()` | 任何错误 | 显示 toast 提示,重新触发 `loadData()` 刷新列表 |
---
## 9.5 账号二维码 & 扫一扫加好友(⚠️ 必须实现)
> 🔴 **强制交付**:ContactsTab 必须有「扫一扫」按钮,SettingsTab 必须有「我的二维码」入口。
> 二维码是加好友的核心交互方式,不可跳过。
### 二维码协议格式dao://add/
示例:`dao://add/u12345678`
> 解析规则:App 扫到任何以 `dao://add/` 开头的字符串,提取后面的 alias_id,
> 调用 `lookupUser(aliasId)` → `sendFriendRequest(aliasId)`。
### 依赖
```bash
npm install qrcode html5-qrcode
npm install -D @types/qrcode「我的二维码」弹窗组件(SettingsTab 入口 / ContactsTab 入口均可触发)
import QRCode from 'qrcode';
import { useState, useEffect, useRef } from 'react';
function MyQRCodeDialog({ aliasId, nickname, onClose }: {
aliasId: string; nickname: string; onClose: () => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvasRef.current) return;
QRCode.toCanvas(canvasRef.current, `dao://add/${aliasId}`, {
width: 220, margin: 2,
color: { dark: '#ffffff', light: '#00000000' }, // 白色码 + 透明底
});
}, [aliasId]);
return (
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center" onClick={onClose}>
<div className="bg-zinc-900 rounded-2xl p-8 w-80 text-center" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-white mb-1">{nickname}</h3>
<p className="text-sm text-zinc-400 mb-4 font-mono">{aliasId}</p>
<div className="bg-zinc-800 rounded-xl p-4 inline-block mb-4">
<canvas ref={canvasRef} />
</div>
<p className="text-xs text-zinc-500">Scan to add me as a friend</p>
</div>
</div>
);
}「扫一扫」组件(摄像头扫码 → 自动加好友)
import { Html5Qrcode } from 'html5-qrcode';
import { useState, useRef, useEffect } from 'react';
import { client } from '@/lib/imClient';
function ScanQRDialog({ onClose, onSuccess }: {
onClose: () => void;
onSuccess: (aliasId: string) => void;
}) {
const [error, setError] = useState('');
const [scannedUser, setScannedUser] = useState<{ alias_id: string; nickname: string } | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const scannerRef = useRef<Html5Qrcode | null>(null);
useEffect(() => {
const scanner = new Html5Qrcode('qr-reader');
scannerRef.current = scanner;
scanner.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 250, height: 250 } },
async (decodedText) => {
// 解析 dao:// 协议
const match = decodedText.match(/^dao:\/\/add\/(\S+)$/);
if (!match) { setError('Not a valid DAO Message QR code'); return; }
const aliasId = match[1];
scanner.pause();
try {
const user = await client.contacts.lookupUser(aliasId);
setScannedUser(user);
} catch {
setError('User not found');
scanner.resume();
}
},
() => {} // ignore scan failure
).catch(() => setError('Camera access denied'));
return () => { scanner.stop().catch(() => {}); };
}, []);
// 确认发送好友请求
const handleConfirm = async () => {
if (!scannedUser || isProcessing) return;
setIsProcessing(true);
try {
await client.contacts.sendFriendRequest(scannedUser.alias_id);
onSuccess(scannedUser.alias_id);
onClose();
} catch (e: any) {
if (e.message?.includes('409')) {
setError('Friend request already exists');
} else {
setError(e.message || 'Failed to send request');
}
setIsProcessing(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/80 flex flex-col items-center justify-center">
<div className="bg-zinc-900 rounded-2xl p-6 w-80">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-white">Scan QR Code</h3>
<button onClick={onClose} className="text-zinc-400 hover:text-white p-2">✕</button>
</div>
{/* 扫码区域 */}
{!scannedUser && <div id="qr-reader" className="rounded-xl overflow-hidden mb-4" />}
{/* 扫到用户 → 确认卡片 */}
{scannedUser && (
<div className="text-center py-4">
<div className="w-16 h-16 mx-auto rounded-full bg-zinc-800 flex items-center justify-center text-xl font-bold text-white mb-3">
{scannedUser.nickname.slice(0, 2).toUpperCase()}
</div>
<p className="text-white font-bold">{scannedUser.nickname}</p>
<p className="text-sm text-zinc-400 font-mono">{scannedUser.alias_id}</p>
<button onClick={handleConfirm} disabled={isProcessing}
className="mt-4 w-full py-3 rounded-xl bg-blue-600 text-white font-bold disabled:opacity-40">
{isProcessing ? 'Sending...' : 'Add Friend'}
</button>
</div>
)}
{error && <p className="text-red-400 text-sm text-center mt-2">{error}</p>}
</div>
</div>
);
}在 ContactsTab 中集成
// ContactsTab 顶栏加按钮
const [showMyQR, setShowMyQR] = useState(false);
const [showScan, setShowScan] = useState(false);
// 顶栏
<div className="flex items-center justify-between px-4 py-3">
<h2 className="text-lg font-bold">Contacts</h2>
<div className="flex gap-2">
{/* 我的二维码 */}
<button onClick={() => setShowMyQR(true)} className="p-2 text-zinc-400 hover:text-white">
<QrCode className="w-5 h-5 pointer-events-none" />
</button>
{/* 扫一扫 */}
<button onClick={() => setShowScan(true)} className="p-2 text-zinc-400 hover:text-white">
<Scan className="w-5 h-5 pointer-events-none" />
</button>
{/* 手动输入 ID 添加 */}
<button onClick={() => setShowAddDialog(true)} className="p-2 text-zinc-400 hover:text-white">
<UserPlus className="w-5 h-5 pointer-events-none" />
</button>
</div>
</div>
{showMyQR && <MyQRCodeDialog aliasId={aliasId} nickname={nickname} onClose={() => setShowMyQR(false)} />}
{showScan && <ScanQRDialog onClose={() => setShowScan(false)} onSuccess={(id) => { loadData(); }} />}在 SettingsTab 中增加入口
// SettingsTab 个人信息区域增加「我的二维码」按钮
<button onClick={() => setShowMyQR(true)}
className="flex items-center gap-3 w-full p-4 rounded-xl bg-zinc-800/50 hover:bg-zinc-700/50 text-left">
<QrCode className="w-5 h-5 text-zinc-400 pointer-events-none" />
<span className="text-white">My QR Code</span>
</button>十、ChatWindow.tsx — 聊天室
SDK 精确类型定义(摘自 @daomessage_sdk/sdk 源码,AI 必须严格按此字段名编写)
// 来自 sdk-typescript/src/keys/store.ts
interface StoredIdentity {
uuid: string
aliasId: string // ← camelCase(IndexedDB 本地存储对象)
nickname: string
mnemonic: string
signingPublicKey: string // Base64
ecdhPublicKey: string // Base64 ← computeSecurityCode 第一个参数用 fromBase64(ident.ecdhPublicKey)
}
interface SessionRecord {
conversationId: string // ← camelCase(IndexedDB 主键)
theirAliasId: string // ← camelCase
theirEcdhPublicKey: string // Base64 ← computeSecurityCode 第二个参数用 fromBase64(s.theirEcdhPublicKey)
theirEd25519PublicKey?: string // Base64,对方签名公钥(可选)
sessionKeyBase64: string
trustState: 'unverified' | 'verified'
createdAt: number
}
// 来自 sdk-typescript/src/messaging/store.ts
interface StoredMessage {
id: string
conversationId: string // ← camelCase
text: string
isMe: boolean
time: number
status: 'sending' | 'sent' | 'delivered' | 'read' | 'failed'
msgType?: string
mediaUrl?: string
caption?: string
seq?: number // 已读回执需要
fromAliasId?: string // ← camelCase,发送方 alias_id
}
// computeSecurityCode 精确签名
// function computeSecurityCode(myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): string
// 返回 60 位 hex 字符串(30 字节 SHA-256),App 展示前 8 位让用户比对10.1 初始化(加载历史 + 安全码 + 实时监听 + 分页)
// V1.1 新增:分页状态
const PAGE_SIZE = 20;
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!activeChatId) return;
// 👤 App:进入会话时重置未读计数
useAppStore.getState().clearUnread(activeChatId);
// 🔒 SDK:loadSession() → 读 IndexedDB sessions store(包含 theirEcdhPublicKey、trustState)
loadSession(activeChatId).then(async (s) => {
setSessionInfo(s || null);
setTrustVerified(s?.trustState === 'verified');
if (s) {
// 🔒 SDK:loadIdentity() → 读 IndexedDB identity store
const ident = await loadIdentity();
if (ident) {
// 🔒 SDK:computeSecurityCode() → SHA-256(排序后的两个公钥) 的前 30 字节 → 60 位 hex
const code = computeSecurityCode(
fromBase64(ident.ecdhPublicKey),
fromBase64(s.theirEcdhPublicKey)
);
setSecurityCode(code); // 👤 App:存入组件 state 展示给用户
}
}
});
// 🔒 SDK:getHistory() → 读 IndexedDB messages store(已解密的 StoredMessage[])
// V1.1 新增:分页加载,首次只加载最新 PAGE_SIZE 条
client.getHistory(activeChatId, { limit: PAGE_SIZE }).then(stored => {
setMessages(stored);
setHasMore(stored.length >= PAGE_SIZE);
});
// 👤 App:绑定实时消息处理器
const handleIncoming = (msg: StoredMessage) => {
if (msg.conversationId !== activeChatId) return;
// 🔒 SDK:收到 WS 帧 → 解密 → 存 IndexedDB → emit 'message'(含已解密的 StoredMessage)
// 👤 App:更新 React state(防重复:按 id 替换)
setMessages(prev => {
const idx = prev.findIndex(m => m.id === msg.id);
const chatMsg = { id: msg.id, text: msg.text, isMe: msg.isMe, time: msg.time, status: msg.status, msgType: msg.msgType };
if (idx >= 0) { const next = [...prev]; next[idx] = chatMsg; return next; }
return [...prev, chatMsg];
});
// 👤 App → SDK:触发已读回执
// 🔒 SDK:发送 {type:'read', conv_id, seq, to} WS 帧
if (!msg.isMe && msg.seq && sessionInfo?.theirAliasId) {
client.markAsRead(activeChatId, msg.seq, sessionInfo.theirAliasId);
}
};
// 🔒 SDK:status_change 事件(sent/delivered/read/failed)
// 👤 App:更新消息的 status 字段,驱动双勾 UI
const handleStatus = (status: { id: string; status: string }) => {
setMessages(prev => prev.map(m =>
m.id === status.id ? { ...m, status: status.status as any } : m
));
};
localMessageHandlers.add(handleIncoming);
localStatusHandlers.add(handleStatus);
return () => {
localMessageHandlers.delete(handleIncoming);
localStatusHandlers.delete(handleStatus);
};
}, [activeChatId]);10.1.1 历史消息分页加载(V1.1 新增)
👤 App 全部实现 —
IntersectionObserver监听滚动到顶部
// 👤 App:滚动到顶部哨兵时加载更多历史消息
useEffect(() => {
if (!sentinelRef.current || !hasMore) return;
const observer = new IntersectionObserver(async ([entry]) => {
if (!entry.isIntersecting || loadingMore || !hasMore || !activeChatId) return;
setLoadingMore(true);
const oldestTime = messages[0]?.time;
if (!oldestTime) { setLoadingMore(false); return; }
// 🔒 SDK:getHistory({ limit, before }) → IndexedDB cursor 分页
const older = await client.getHistory(activeChatId, {
limit: PAGE_SIZE,
before: oldestTime,
});
if (older.length < PAGE_SIZE) setHasMore(false);
// 👤 App:保持滚动位置(记住旧 scrollHeight,插入后恢复)
const container = scrollContainerRef.current;
const prevHeight = container?.scrollHeight || 0;
setMessages(prev => [...older, ...prev]);
requestAnimationFrame(() => {
if (container) container.scrollTop = container.scrollHeight - prevHeight;
});
setLoadingMore(false);
}, { root: scrollContainerRef.current, threshold: 0.1 });
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [hasMore, loadingMore, messages, activeChatId]);
// 👤 App:渲染哨兵元素(放在消息列表最顶部)
// <div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
// {hasMore && <div ref={sentinelRef}>{loadingMore && <Loader2 spin />}</div>}
// {messages.map(...)}
// </div>10.2 发送消息
// 发送文本
// 🔒 SDK:sendMessage() 内部:取 IndexedDB 会话密钥 → AES-256-GCM 加密 → WS 发送 → 本地持久化
await client.sendMessage(activeChatId, sessionInfo.theirAliasId, inputText);
// 发送图片(推荐方式:直接用 sendImage 传入 thumbnail)
// 🔒 SDK:压缩图片 → 加密上传 R2 → 自动打包 JSON payload 并发送
// 👤 App:生成 thumbnail(可选,内联图 Base64 或管理器外 Data URL)
const thumbnail = await generateBlurryThumbnail(file); // 建议 32px JPEG 30%
await client.sendImage(activeChatId, sessionInfo.theirAliasId, file, thumbnail);
// └── SDK 内部自动: uploadEncryptedFile → 拼接 { type:'image', key, thumbnail } → sendMessage
// 如果不需要骨架屏,不传 thumbnail 即可
await client.sendImage(activeChatId, sessionInfo.theirAliasId, file);
// 发送"正在输入"(App 层需在 300ms 防抖后触发)
// 🔒 SDK:发送 {type:'typing'} WS 帧
client.sendTyping(activeChatId, sessionInfo.theirAliasId);
// 接收对方"正在输入"状态(新增)
// 🔒 SDK:收到 {type:'typing', from, conv_id} WS 帧 → emit 'typing' 事件
// 👤 App:添加 typing 订阅并再 3s 后自动清除
useEffect(() => {
const handleTyping = ({ fromAliasId, conversationId }: TypingEvent) => {
if (conversationId !== activeChatId) return;
setFriendTyping(true); // 👤 App: 显示 “对方正在输入...”
clearTimeout(typingTimerRef.current);
typingTimerRef.current = window.setTimeout(() => setFriendTyping(false), 3000);
};
localTypingHandlers.add(handleTyping);
return () => { localTypingHandlers.delete(handleTyping); };
}, [activeChatId]);10.3 强制双边密钥核对(⚠️ 必须实现,不可跳过 + 不可绕过)
🔴 协议级强制:每对会话必须双方都完成核对(
trustState='verified')才能聊天。这不是可选功能、不是可跳过的引导。 SDK 协议层在未核对时client.messages.send()会抛UNVERIFIED_SESSION异常,服务端协议层也会拒绝转发——UI 不阻塞用户,依然发不出消息。完整算法和协议设计见 Developer Guide 的 Security 章节(doc.daomessage.com/en/guide/security)。
10.3.1 三状态机(必须区分)
import type { SessionTrustState } from '@daomessage_sdk/sdk';
// 'unverified' | 'my_side_verified' | 'verified'
const [trustState, setTrustState] = useState<SessionTrustState>('unverified');
// 进入聊天时读取
useEffect(() => {
loadSession(activeChatId!).then(s => {
setTrustState((s?.trustState as SessionTrustState) ?? 'unverified');
});
}, [activeChatId]);
// 监听 SDK trustState 变化(服务端 NATS 通知 → SDK 升级 → 触发回调)
useEffect(() => {
const inner = (client as any).messages?.inner;
if (!inner) return;
const old = inner.onTrustStateChange;
inner.onTrustStateChange = async ({ conversationId: cId }: any) => {
if (old) old({ conversationId: cId });
if (cId !== activeChatId) return;
const fresh = await loadSession(activeChatId);
const newState = (fresh?.trustState as SessionTrustState) ?? 'unverified';
setTrustState(newState);
// 🔓 双方都完成核对 → 自动关 modal + 解锁聊天
if (newState === 'verified') setShowVerifyModal(false);
};
return () => { inner.onTrustStateChange = old; };
}, [activeChatId]);10.3.2 三状态 overlay(区分文案 + 区分颜色)
import { ShieldAlert, ShieldCheck, ShieldOff, Loader2 } from 'lucide-react';
const trustVerified = trustState === 'verified';
// Header 角标三态
{trustVerified ? (
<ShieldCheck className="w-4 h-4 text-green-500" />
) : trustState === 'my_side_verified' ? (
<button onClick={() => setShowVerifyModal(true)}
className="flex items-center gap-1 bg-blue-500/10 text-blue-400 px-1.5 py-0.5 rounded text-[10px] border border-blue-500/30">
<ShieldCheck className="w-3 h-3" /> Awaiting peer
</button>
) : (
<button onClick={() => setShowVerifyModal(true)}
className="flex items-center gap-1 bg-yellow-500/10 text-yellow-500 px-1.5 py-0.5 rounded text-[10px] border border-yellow-500/30">
<ShieldAlert className="w-3 h-3" /> Unverified
</button>
)}
// 阻塞聊天界面的 overlay(非 verified 时盖住整个聊天区)
{!trustVerified && session?.friendshipId && activeChatId && (
<div className="absolute inset-0 z-30 bg-zinc-950/95 backdrop-blur-sm flex items-center justify-center p-6">
<div className="max-w-sm w-full text-center space-y-5">
{trustState === 'my_side_verified' ? (
<>
<div className="w-16 h-16 mx-auto bg-blue-500/20 rounded-2xl flex items-center justify-center">
<ShieldCheck className="w-8 h-8 text-blue-400" />
</div>
<h3 className="text-xl font-bold text-white">Verified your side</h3>
<p className="text-sm text-zinc-400">
Waiting for <strong className="text-zinc-200">@{theirAliasId}</strong> to verify too.
Chat unlocks automatically once both sides complete verification.
<span className="inline-flex items-center gap-1.5 text-xs text-zinc-500 mt-3">
<Loader2 className="w-3 h-3 animate-spin" /> Waiting...
</span>
</p>
<button onClick={() => setShowVerifyModal(true)}
className="w-full py-3 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm">
View verification details
</button>
</>
) : (
<>
<div className="w-16 h-16 mx-auto bg-amber-500/20 rounded-2xl flex items-center justify-center">
<ShieldOff className="w-8 h-8 text-amber-400" />
</div>
<h3 className="text-xl font-bold text-white">Verify Security Code</h3>
<p className="text-sm text-zinc-400">
Both sides must verify each other's keys via an external channel
(WeChat / email / SMS) before this chat unlocks. Messages cannot be
sent or decrypted until verification completes.
</p>
<button onClick={() => setShowVerifyModal(true)}
className="w-full py-3.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold">
Start verification
</button>
</>
)}
</div>
</div>
)}10.3.3 KeyVerificationModal(三视图:input / waiting / loading)
import {
computeDirectionalCode, normalizeDirectionalCode, computeSharedSecret,
computeSecurityCode, formatSecurityCode,
loadIdentity, deriveIdentity, loadSession, fromBase64,
markMyVerified,
} from '@daomessage_sdk/sdk';
type ModalView = 'loading' | 'input' | 'waiting';
const [view, setView] = useState<ModalView>('loading');
const [myCode, setMyCode] = useState('');
const [expectedTheirCode, setExpectedTheirCode] = useState('');
const [fingerprint60, setFingerprint60] = useState('');
const [inputCode, setInputCode] = useState('');
const [attempts, setAttempts] = useState(0);
const [error, setError] = useState<'' | 'length' | 'mismatch' | 'mismatch_3'>('');
const [submitting, setSubmitting] = useState(false);
// 打开 modal 时计算双向码 + 决定初始视图
useEffect(() => {
(async () => {
const session = await loadSession(conversationId);
const stored = await loadIdentity();
if (!session || !stored?.mnemonic) return;
const ident = deriveIdentity(stored.mnemonic);
const myPriv = ident.ecdhKey.privateKey;
const myPub = ident.ecdhKey.publicKey;
const theirPub = fromBase64(session.theirEcdhPublicKey);
const shared = computeSharedSecret(myPriv, theirPub);
setMyCode(computeDirectionalCode(shared, myPub, theirPub));
setExpectedTheirCode(computeDirectionalCode(shared, theirPub, myPub));
setFingerprint60(computeSecurityCode(myPub, theirPub));
// ⚠️ 关键 UX:已经核对过的会话,再开 modal 直接进 waiting 视图,不要又显示输入框
setView(session.trustState === 'my_side_verified' ? 'waiting' : 'input');
})();
}, [conversationId]);
const handleVerify = async () => {
setError('');
const inputN = normalizeDirectionalCode(inputCode);
const expectN = normalizeDirectionalCode(expectedTheirCode);
const myLen = normalizeDirectionalCode(myCode).length;
if (inputN.length !== myLen) { setError('length'); return; }
if (inputN !== expectN) {
const next = attempts + 1;
setAttempts(next);
setError(next >= 3 ? 'mismatch_3' : 'mismatch');
return;
}
setSubmitting(true);
try {
await markMyVerified(
'https://relay.daomessage.com',
await (client as any).http.getToken(),
friendshipId,
conversationId,
);
// 🔒 关键:成功后**不立即关闭 modal**,切换到等待视图
// 父组件 onTrustStateChange 监听到 verified 时会自动关 modal
setView('waiting');
} catch (e: any) {
setError(`Submit failed: ${e?.message || 'network error'}` as any);
} finally {
setSubmitting(false);
}
};
// 分享 / 复制(用户必须把 myCode 通过外部渠道发给对方)
const handleShare = async () => {
const text = `My DAO Message verification code (with @${theirAliasId}):\n\n${myCode}\n\nPaste this into your DAO Message verification window. If the codes don't match, possible MITM attack — stop using this conversation.`;
if ((navigator as any).share) {
try { await (navigator as any).share({ title: 'DAO Message', text }); return; } catch {}
}
await navigator.clipboard.writeText(text);
alert('Copied — paste into WeChat/email/SMS to send to peer');
};// modal 渲染(三视图)
return (
<div className="fixed inset-0 z-[10001] bg-black/70 flex items-center justify-center p-4"
onClick={onClose}>
<div className="bg-zinc-950 border border-zinc-800 rounded-2xl p-6 w-full max-w-md"
onClick={e => e.stopPropagation()}>
{view === 'loading' && (
<div className="py-12 flex flex-col items-center gap-3">
<Loader2 className="w-6 h-6 text-zinc-500 animate-spin" />
<div className="text-sm text-zinc-500">Loading verification state...</div>
</div>
)}
{view === 'waiting' && (
<>
<h3 className="text-xl font-bold mb-2 flex items-center gap-2">
<ShieldCheck className="w-6 h-6 text-blue-400" /> Verified your side
</h3>
<p className="text-zinc-400 text-xs mb-4">
You confirmed @{theirAliasId}'s code. Waiting for them to verify yours.
</p>
<div className="bg-zinc-900 rounded-xl p-4 font-mono text-base text-center mb-2 select-all tracking-wider">
{myCode}
</div>
<div className="grid grid-cols-2 gap-2 mb-4">
<button onClick={handleShare} className="py-2 bg-zinc-800 rounded-lg text-xs">Re-share</button>
<button onClick={() => navigator.clipboard.writeText(myCode)} className="py-2 bg-zinc-800 rounded-lg text-xs">Copy</button>
</div>
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-3 mb-4">
<div className="flex items-center gap-2 text-blue-300 text-sm font-medium mb-1">
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Awaiting peer
</div>
<div className="text-xs text-zinc-400">
Chat unlocks automatically once @{theirAliasId} also verifies. You can close this dialog;
you'll be notified when both sides are done.
</div>
</div>
<button onClick={onClose} className="w-full py-3 rounded-lg bg-zinc-800 text-zinc-300">Close</button>
</>
)}
{view === 'input' && (
<>
<h3 className="text-xl font-bold mb-2 flex items-center gap-2">
<ShieldCheck className="w-6 h-6 text-blue-400" /> Verify Security Code
</h3>
<p className="text-zinc-400 text-xs mb-4">
Send your code to <strong>@{theirAliasId}</strong> via WeChat / email / SMS.
Then paste their code below. <strong className="text-zinc-300">It is normal that
both sides see different codes</strong> (derived directionally from your shared key).
</p>
{/* 我的码(给用户分享) */}
<div className="text-xs text-zinc-500 mb-2">My code (send to peer):</div>
<div className="bg-zinc-900 rounded-xl p-4 font-mono text-base text-center mb-2 select-all tracking-wider">
{myCode || 'Computing...'}
</div>
<div className="grid grid-cols-2 gap-2 mb-4">
<button onClick={handleShare} className="py-2 bg-blue-600 rounded-lg text-xs text-white">Share</button>
<button onClick={() => navigator.clipboard.writeText(myCode)} className="py-2 bg-zinc-800 rounded-lg text-xs">Copy</button>
</div>
{/* 对方的码(用户输入) */}
<div className="text-xs text-zinc-500 mb-2">Peer's code (received from them):</div>
<input value={inputCode}
onChange={e => { setInputCode(e.target.value); if (error === 'length' || error === 'mismatch') setError(''); }}
placeholder="Paste peer's code (dashes/spaces ok)"
className="w-full bg-zinc-900 rounded-lg py-3 px-4 text-sm font-mono mb-3" />
{error === 'mismatch' && (
<div className="text-red-400 text-xs bg-red-500/10 rounded-lg p-2.5 mb-3">
Codes don't match — check carefully ({attempts}/3)
</div>
)}
{error === 'mismatch_3' && (
<div className="bg-red-500/10 border-2 border-red-500/50 rounded-lg p-3 mb-3 text-xs">
<div className="font-bold text-red-400 mb-1">⚠️ 3 mismatches — serious warning</div>
<div className="text-zinc-300">
Likely a copy-paste error, or possibly a MITM attack on the relay.
Strongly recommend deleting this contact and re-adding via a fresh QR scan.
</div>
</div>
)}
<div className="flex gap-3">
<button onClick={onClose} className="flex-1 py-3 bg-zinc-800 rounded-lg text-zinc-300 text-sm">
Later
</button>
<button onClick={handleVerify}
disabled={submitting || !myCode || !inputCode}
className="flex-1 py-3 bg-blue-600 rounded-lg text-white text-sm font-medium disabled:opacity-50">
{submitting ? 'Submitting...' : 'Verify'}
</button>
</div>
</>
)}
</div>
</div>
);10.3.4 send 错误兜底(SDK 协议层 gate)
即使 UI overlay 写错或被绕过,SDK 仍会拦截:
try {
await client.sendMessage(conversationId, toAliasId, text);
} catch (e: any) {
if (e?.kind === 'UNVERIFIED_SESSION' || /unverified/i.test(e?.message)) {
setShowVerifyModal(true);
return; // 强制用户先核对
}
// 其他错误...
}10.3.5 不要做的事
- ❌ 不要做"跳过核对"按钮
- ❌ 不要把双方的码做成相同(违反方案 Y 设计意图,且容易被服务端钓鱼"伪装相同码")
- ❌ 不要在
markMyVerified成功后立即关闭 modal(用户会以为没核对成功) - ❌ 不要让 trustState 直接从
unverified跳到verified(必须经过my_side_verified中间态) - ❌ 不要比较原始字符串(必须用
normalizeDirectionalCode()归一化后再===)
10.4 ImageBubble 组件(骨架屏 + Lightbox)
function ImageBubble({ mediaKey, thumbnail, conversationId }: {
mediaKey: string; thumbnail?: string; conversationId: string;
}) {
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
let active = true, objectUrl = '';
// 🔒 SDK:downloadDecryptedMedia → 下载密文 → 流式解密 → 返回 ArrayBuffer
// 👤 App:创建 Blob Object URL 用于 <img> 展示
client.media.downloadDecryptedMedia(mediaKey, conversationId)
.then(buffer => {
if (!active) return;
objectUrl = URL.createObjectURL(new Blob([buffer]));
setUrl(objectUrl);
})
.catch(() => { if (active) setError(true); });
return () => { active = false; if (objectUrl) URL.revokeObjectURL(objectUrl); };
}, [mediaKey]);
if (error) return <div className="text-sm text-red-400">⚠️ 图片加载失败</div>;
// 👤 App:骨架屏(thumbnail 存在时:模糊放大 + Loader 旋转)
if (!url) return thumbnail
? <div className="relative max-w-[200px] rounded overflow-hidden">
<img src={thumbnail} className="w-full blur-sm scale-110 opacity-70" />
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<Loader2 className="w-6 h-6 animate-spin text-white" />
</div>
</div>
: <span className="text-zinc-400 text-sm flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />正在拉取高清原图...
</span>;
// 👤 App:点击缩略图 → 全屏 Lightbox
return <>
<div className="cursor-zoom-in" onClick={() => setIsFullscreen(true)}>
<img src={url} className="max-w-[200px] max-h-[250px] rounded object-cover" />
</div>
{isFullscreen && (
<div className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center backdrop-blur-sm cursor-zoom-out"
onClick={() => setIsFullscreen(false)}>
<img src={url} className="max-w-full max-h-full object-contain"
onClick={e => e.stopPropagation()} />
<button className="absolute top-4 right-4 p-2 bg-zinc-800/50 rounded-full text-white"
onClick={() => setIsFullscreen(false)}>关闭</button>
</div>
)}
</>;
}10.5 消息类型推断(未知类型降级)
V1.2 新增
file和voice消息类型
消息 payload 协议约定(通过 E2EE 信封传输的 JSON 内容):
| 类型 | JSON payload 格式 | 说明 |
|---|---|---|
text | 纯文本字符串 | 普通文本消息 |
image | {"type":"image","key":"media_key","thumbnail":"base64"} | 图片消息,key 为 R2 media_key |
file | {"type":"file","key":"media_key","name":"原始文件名","size":字节数} | 文件消息 |
voice | {"type":"voice","key":"media_key","duration":毫秒数} | 语音消息 |
retracted | 消息已撤回 (msgType 字段标记) | 撤回消息,由 SDK 自动处理 |
// 👤 App:根据 msgType 字段或文本内容特征推断渲染方式
const inferType = (m: ChatMessage) => {
if (m.msgType) return m.msgType;
if (m.text?.startsWith('[img]')) return 'image';
if (m.text?.startsWith('[file]')) return 'file';
if (m.text?.startsWith('[voice]')) return 'voice';
try {
const parsed = JSON.parse(m.text);
if (parsed.type === 'image') return 'image';
if (parsed.type === 'file') return 'file';
if (parsed.type === 'voice') return 'voice';
} catch {}
return 'text';
};
// 未知类型 fallback(未来扩展兼容)
if (!['text','image','file','voice'].includes(inferType(m))) {
return <div className="text-xs text-zinc-500 italic">
⚠️ 你的客户端不支持此消息类型,请升级 App
</div>;
}
// 👤 App:引用回复预览辅助函数(将消息 text 转为友好文本)
// ⚠️ 直接用 m.text.slice(0,N) 会导致文件/语音消息显示原始 JSON
const getPreviewText = (m: StoredMessage, maxLen = 50): string => {
if (m.msgType === 'retracted') return '消息已撤回';
const t = inferType(m);
if (t === 'image') return '📷 图片';
if (t === 'voice') {
try { const p = JSON.parse(m.text); return `🎤 语音 ${Math.round((p.duration || 0) / 1000)}s`; } catch {}
return '🎤 语音消息';
}
if (t === 'file') {
try { const p = JSON.parse(m.text); return `📎 ${p.name || '文件'}`; } catch {}
return '📎 文件';
}
return m.text?.slice(0, maxLen) || '[消息]';
};SDK 发送接口(V1.2 新增):
// 🔒 SDK:文件发送(不压缩,直接 AES-GCM 分片加密上传)
await client.sendFile(conversationId, toAliasId, file)
// 🔒 SDK:语音发送(录音 Blob 直接加密上传)
await client.sendVoice(conversationId, toAliasId, audioBlob, durationMs)10.6 消息撤回(V1.1 新增)
🔒 SDK:
client.retractMessage()内部自动发送{type:'retract'}WS 帧 + 本地替换为msgType:'retracted'系统消息
👤 App:仅负责 UI 触发入口(右键菜单「撤回」按钮)+ 撤回消息渲染
// ── 发起撤回 ──
// 🔒 SDK:client.retractMessage(messageId, toAliasId, conversationId)
// 1. 发送 WS 帧 {type:'retract', id, to, conv_id}
// 2. 本地 IndexedDB 替换原消息为 {text:'消息已撤回', msgType:'retracted'}
// 3. emit 'message' 事件通知 UI 层
// ⚠️ 仅可撤回自己发送的消息(isMe === true),不限时间
await client.retractMessage(msgId, sessionInfo.theirAliasId, activeChatId);
// ── 接收对方撤回 ──
// 🔒 SDK 自动处理:收到 {type:'retract'} WS 帧 → 存 IndexedDB → emit 'message'
// 👤 App 无需额外代码,已在 10.1 handleIncoming 中自动处理
// ── 撤回消息渲染 ──
// 👤 App:检测 m.msgType === 'retracted' → 居中灰色提示(不渲染气泡)
if (m.msgType === 'retracted') {
return (
<div className="flex justify-center">
<span className="text-xs text-zinc-500 italic bg-zinc-900/50 px-3 py-1 rounded-full">
{m.isMe ? '你撤回了一条消息' : '对方撤回了一条消息'}
</span>
</div>
);
}
// ⚠️ inferType 也需要更新:
const inferType = (m: ChatMessage) => {
if (m.msgType === 'retracted') return 'retracted'; // ← 新增
if (m.msgType) return m.msgType;
// ... 原有逻辑
};10.7 引用回复(V1.1 新增)
🔒 SDK:
send()现支持replyToId可选参数,内部自动 JSON 包裹{text, replyToId}后加密
👤 App:维护replyTo状态、渲染预览条和引用卡片
// ── Zustand / useState ──
// 👤 App:组件级状态,选中要引用的消息
const [replyTo, setReplyTo] = useState<StoredMessage | null>(null);
// ── 触发(右键菜单「回复」)──
// 👤 App:设置 replyTo + 聚焦输入框
setReplyTo(messages.find(m => m.id === contextMenu.msgId) || null);
inputRef.current?.focus();
// ── 发送引用回复 ──
// 🔒 SDK:send() 加密时自动包裹 {text, replyToId} → 对方 SDK 解密时自动提取
// ⚠️ 第四个参数 replyToId 是可选 string,不是对象
await client.sendMessage(activeChatId, sessionInfo.theirAliasId, inputText, replyTo?.id);
setReplyTo(null); // 发送后清空
// ── 输入框上方预览条 ──
// 👤 App:replyTo 非空时显示引用预览 + 关闭按钮
// ⚠️ 预览文本不能直接用 replyTo.text,因为文件/语音/图片消息的 text 是 JSON
// 必须用 getPreviewText 辅助函数将其转为友好文本(📷 图片 / 📎 文件名 / 🎤 语音 3s)
{replyTo && (
<div className="flex items-center gap-2 px-4 py-2 bg-zinc-800 border-t border-zinc-700">
<Reply className="w-4 h-4 text-blue-400" />
<span className="text-xs text-zinc-400 truncate flex-1">
回复: {getPreviewText(replyTo)}
</span>
<button onClick={() => setReplyTo(null)}>
<X className="w-4 h-4 text-zinc-500" />
</button>
</div>
)}
// ── 消息气泡内引用卡片 ──
// SDK 在 StoredMessage 上新增了 replyToId?: string 字段
// 👤 App:消息渲染时检查 m.replyToId,查找被引用消息并渲染引用条
{m.replyToId && (() => {
const quoted = messages.find(q => q.id === m.replyToId);
if (!quoted) return null;
// ⚠️ 必须使用 getPreviewText 解析多媒体消息,不能直接截取 text(否则文件/语音显示原始 JSON)
const preview = getPreviewText(quoted, 60);
return (
<div className={cn(
"text-[11px] mb-1.5 border-l-2 pl-2 py-0.5 rounded-sm truncate",
m.isMe ? "border-blue-300/50 text-blue-100/70" : "border-zinc-500/50 text-zinc-400"
)}>
<span className="font-medium">{quoted.isMe ? '你' : (quoted.fromAliasId?.slice(0, 6) || '对方')}</span>
<span className="ml-1">{preview}</span>
</div>
);
})()}10.8 右键/长按操作菜单(V1.1 新增)
👤 App 全部实现 — SDK 不管 UI 交互方式
// ── 组件状态 ──
const [contextMenu, setContextMenu] = useState<{ msgId: string; x: number; y: number } | null>(null);
// ── 触发 ──
// 🖱️ PC:onContextMenu(右键),📱 Mobile:onTouchStart/End(长按 500ms)
// ⚠️ 限制:m.status !== 'failed' 时才允许弹出
<div onContextMenu={(e) => {
if (m.status !== 'failed') {
e.preventDefault();
setContextMenu({ msgId: m.id, x: e.clientX, y: e.clientY });
}
}}>
// ── 菜单渲染 ──
// 底部三个按钮:撤回(仅自己消息)、回复、详情
{contextMenu && (() => {
const targetMsg = messages.find(m => m.id === contextMenu.msgId);
const isOwn = targetMsg?.isMe ?? false;
return (
<div className="fixed z-50 bg-zinc-800 border border-zinc-700 rounded-xl shadow-2xl py-1 min-w-[120px]"
style={{ top: contextMenu.y, left: Math.min(contextMenu.x, window.innerWidth - 140) }}
onClick={() => setContextMenu(null)}>
{isOwn && (
<button className="w-full text-left px-4 py-2.5 text-sm text-red-400"
onClick={async () => {
await client.retractMessage(contextMenu.msgId, sessionInfo.theirAliasId, activeChatId);
setContextMenu(null);
}}>撤回</button>
)}
<button className="..." onClick={() => {
setReplyTo(targetMsg!);
setContextMenu(null);
}}>回复</button>
<button className="..." onClick={() => {
setDetailMsg(targetMsg!);
setContextMenu(null);
}}>详情</button>
</div>
);
})()}
// ── 点击空白区域关闭 ──
<div onClick={() => contextMenu && setContextMenu(null)}
onScroll={() => contextMenu && setContextMenu(null)}>10.9 消息详情弹窗(V1.1 新增)
👤 App 全部实现 — 从
StoredMessage字段直接渲染
// ── 状态 ──
const [detailMsg, setDetailMsg] = useState<StoredMessage | null>(null);
// ── 弹窗 UI ──
{detailMsg && (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40"
onClick={() => setDetailMsg(null)}>
<div className="bg-zinc-900 w-full max-w-md rounded-t-2xl p-6"
onClick={e => e.stopPropagation()}>
<h3 className="text-sm font-semibold text-zinc-200 flex items-center gap-2 mb-4">
<Info className="w-4 h-4 text-blue-400" /> 消息详情
</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-zinc-500">消息 ID</span>
<span className="text-zinc-300 font-mono text-xs">{detailMsg.id}</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">发送时间</span>
<span className="text-zinc-300">
{new Date(detailMsg.time).toLocaleString('zh-CN')}
</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">发送方</span>
<span className="text-zinc-300">
{detailMsg.isMe ? '我' : (detailMsg.fromAliasId || '对方')}
</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">状态</span>
<span className="text-zinc-300">
{/* ⚠️ delivered 在 UI 层应合并到 sent 显示 — 用户分不清"服务端收到"
和"对方设备收到"的差别,旧版给 delivered 显示"已送达"+ 双勾导致
用户误以为是已读。详见 SDK Developer Guide messaging.md 状态显示规范 */}
{{sending:'发送中',sent:'已发送',delivered:'已发送',read:'已读',failed:'失败'}[detailMsg.status]}
</span>
</div>
</div>
<button className="w-full mt-6 py-3 bg-zinc-800 rounded-xl text-zinc-300"
onClick={() => setDetailMsg(null)}>关闭</button>
</div>
</div>
)}10.10 语音消息录制与播放(⚠️ 必须实现)
🔒 SDK:
client.sendVoice(conversationId, toAliasId, blob, durationMs)— 加密上传语音 Blob + 发送 🔒 SDK:client.media.downloadDecryptedMedia(mediaKey, convId)— 解密下载语音文件 👤 App:录音 UI(MediaRecorderAPI)+ 播放 UI(VoiceBubble组件)
录音交互设计
// 👤 App:长按麦克风按钮录音,松开发送,上滑取消
const [isRecording, setIsRecording] = useState(false);
const [recordDuration, setRecordDuration] = useState(0);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cancelRef = useRef(false);
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
audioChunksRef.current = [];
cancelRef.current = false;
recorder.ondataavailable = (e) => {
if (e.data.size > 0) audioChunksRef.current.push(e.data);
};
recorder.onstop = async () => {
stream.getTracks().forEach(t => t.stop()); // 释放麦克风
if (cancelRef.current) return; // 取消录音
const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
const durationMs = recordDuration * 1000;
if (durationMs < 1000) return; // 不足 1 秒不发送
// 🔒 SDK:加密上传 + 发送语音消息
await client.sendVoice(activeChatId!, sessionInfo!.theirAliasId, blob, durationMs);
};
recorder.start(100); // 每 100ms 产生一个 chunk
mediaRecorderRef.current = recorder;
setIsRecording(true);
setRecordDuration(0);
// 计时器
timerRef.current = setInterval(() => {
setRecordDuration(prev => prev + 1);
}, 1000);
} catch (e) {
console.error('[Voice] 麦克风权限被拒绝:', e);
alert('Microphone access denied. Please allow microphone permission.');
}
};
const stopRecording = () => {
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop();
}
if (timerRef.current) clearInterval(timerRef.current);
setIsRecording(false);
};
const cancelRecording = () => {
cancelRef.current = true;
stopRecording();
};录音按钮 UI(放在输入框旁边)
// 👤 App:输入框右侧的麦克风/发送按钮切换
{inputText.trim() ? (
<button onClick={handleSend} className="p-3 bg-blue-500 rounded-full">
<Send className="w-5 h-5 text-white pointer-events-none" />
</button>
) : (
<button
onMouseDown={startRecording} // PC
onMouseUp={stopRecording}
onMouseLeave={cancelRecording}
onTouchStart={startRecording} // Mobile
onTouchEnd={stopRecording}
className={`p-3 rounded-full transition ${isRecording ? 'bg-red-500 scale-110' : 'bg-zinc-700'}`}
>
<Mic className="w-5 h-5 text-white pointer-events-none" />
</button>
)}
// 录音状态指示器
{isRecording && (
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 bg-red-500/90 text-white px-4 py-2 rounded-full flex items-center gap-2 animate-pulse">
<div className="w-2 h-2 bg-white rounded-full" />
<span className="text-sm font-mono">{Math.floor(recordDuration / 60)}:{(recordDuration % 60).toString().padStart(2, '0')}</span>
<span className="text-xs opacity-70">Release to send • Slide up to cancel</span>
</div>
)}VoiceBubble 组件(解密播放 + 波形动画)
function VoiceBubble({ mediaKey, duration, conversationId, isMe }: {
mediaKey: string; duration: number; conversationId: string; isMe: boolean;
}) {
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const audioRef = useRef<HTMLAudioElement | null>(null);
// 🔒 SDK:解密下载语音
useEffect(() => {
let active = true, url = '';
client.media.downloadDecryptedMedia(mediaKey, conversationId)
.then(buffer => {
if (!active) return;
url = URL.createObjectURL(new Blob([buffer], { type: 'audio/webm' }));
setAudioUrl(url);
setLoading(false);
})
.catch(() => { if (active) setLoading(false); });
return () => { active = false; if (url) URL.revokeObjectURL(url); };
}, [mediaKey]);
const togglePlay = () => {
if (!audioRef.current) return;
if (playing) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setPlaying(!playing);
};
const durationSec = Math.round((duration || 0) / 1000);
return (
<div className={`flex items-center gap-3 px-4 py-3 rounded-2xl min-w-[160px] max-w-[240px] ${
isMe ? 'bg-blue-600' : 'bg-zinc-800'
}`}>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin text-white/60" />
) : (
<button type="button" onClick={togglePlay}
className="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center shrink-0">
{playing
? <Pause className="w-4 h-4 text-white pointer-events-none" />
: <Play className="w-4 h-4 text-white pointer-events-none ml-0.5" />
}
</button>
)}
{/* 简化波形(CSS 动画条) */}
<div className="flex-1 flex items-center gap-[2px] h-6">
{Array.from({ length: 20 }, (_, i) => (
<div key={i}
className={`w-[3px] rounded-full transition-all duration-200 ${
isMe ? 'bg-white/40' : 'bg-zinc-500'
} ${playing ? 'animate-pulse' : ''}`}
style={{
height: `${Math.random() * 60 + 20}%`,
opacity: progress > i / 20 ? 1 : 0.4,
}}
/>
))}
</div>
<span className="text-xs text-white/70 shrink-0 font-mono">{durationSec}s</span>
{audioUrl && (
<audio ref={audioRef} src={audioUrl}
onTimeUpdate={() => {
if (audioRef.current) setProgress(audioRef.current.currentTime / audioRef.current.duration);
}}
onEnded={() => { setPlaying(false); setProgress(0); }}
/>
)}
</div>
);
}
// 在消息渲染中使用:
// if (inferType(m) === 'voice') {
// const { key, duration } = JSON.parse(m.text);
// return <VoiceBubble mediaKey={key} duration={duration} conversationId={activeChatId!} isMe={m.isMe} />;
// }十一、频道系统
SDK 精确类型定义(摘自 @daomessage_sdk/sdk 源码,AI 必须严格按此字段名编写)
// 来自 sdk-typescript/src/channels/manager.ts
interface ChannelInfo {
id: string // 频道 ID(用于 subscribe/unsubscribe/getDetail/getPosts)
name: string
description: string
role?: string // 'owner' | undefined,canPost 依此判断
is_subscribed?: boolean
/** 频道是否处于出售状态(仅 for_sale=true 时显示购买按钮) */
for_sale?: boolean
/** 出售价格(USDT),仅当 for_sale=true 时有值 */
sale_price?: number
}
interface ChannelPost {
id: string
type: string // 'text' | 'image' 等
content: string
created_at: string // ISO 8601 字符串
author_alias_id: string // 发帖者 alias_id(snake_case!)
}
// create() 返回类型
type CreateChannelResult = { channel_id: string }
// postMessage() 返回类型
type PostMessageResult = { post_id: string }
/** 频道交易订单(来自 POST /api/v1/channels/{id}/buy) */
interface ChannelTradeOrder {
order_id: string
/** 单位 USDT */
price_usdt: number
/** TRON 收款地址 */
pay_to: string
/** 订单有效期(ISO 8601) */
expired_at: string
}ChannelsTab.tsx
// 🔒 SDK:channels.getMine() → GET /channels/mine
// 🔒 SDK:channels.search(q) → GET /channels/search?q=
// 🔒 SDK:channels.create(name, desc, true) → POST /channels
// 返回 { channel_id },创建后直接 setActiveChannelId(res.channel_id)
// 👤 App:400ms 防抖搜索(空字符时恢复"我的频道"列表)
// 👤 App:创建频道 Modal(名称必填 + 描述选填)
// 👤 App:搜索结果中,若 channel.for_sale === true,显示价格标签 "🏷 xxx USDT"
// 点击后进入 ChannelDetail,展示购买入口ChannelDetail.tsx
// 🔒 SDK:channels.getDetail(id) → GET /channels/{id}
// 🔒 SDK:channels.getPosts(id) → GET /channels/{id}/posts(服务端返回倒序)
// 👤 App:.reverse() 后渲染(最新在底部)
// 👤 App:WS 实时帖子监听
localChannelPostHandlers.add((data) => {
// 🔒 SDK:收到 {type:'channel_post', conv_id, ...} WS 帧 → emit 'channel_post'
// ⚠️ 产品架构备注:频道的定位为“免打扰的单向公开广播”。服务端在此仅面向当前【在线/即时连接】的 WebSocket 客户端下发 `channel_post` 事件。
// 频道有新帖时【故意设计为不触发任何离线的 Web Push 横幅推送/唤醒】。用户后续点进频道时,将统一依靠 .getPosts() 覆盖拉取最新历史。
if (data.conv_id === activeChannelId) {
// 👤 App:append 到帖子列表或重新拉取
}
});
// 🔒 SDK:channels.canPost(channelInfo) → channelInfo.role === 'owner'(本地计算,无网络请求)
// 👤 App:canPost === true 时才显示输入框(仅频道主可发帖)
// 🔒 SDK:channels.postMessage(channelId, content, type)→ POST /channels/{id}/posts
// 🔒 SDK:channels.subscribe(id) → PUT /channels/{id}/subscribe
// 🔒 SDK:channels.unsubscribe(id) → DELETE /channels/{id}/subscribe频道交易(Owner 挂牌出售 / 买家购买)
频道交易复用靓号商店的支付全链路:Owner 挂牌 → 买家下单 → NOWPayments 链上付款 → pay-worker 自动转移所有权。
Owner 侧:挂牌出售
// 🔒 SDK:channels.listForSale(channelId, priceUsdt)
// → POST /api/v1/vanity/list-channel { channel_id, price_usdt }
// → 204 No Content(成功后 channel.for_sale → true)
//
// 👤 App:ChannelDetail 内,role === 'owner' 时显示「⚙️ 挂牌出售」按钮
// 点击弹出 Modal:输入售价(USDT 整数)→ 调用 listForSale()
// 挂牌成功后刷新详情,显示 "🏷 出售中 - xxx USDT"
// ⚠️ 挂牌后频道不可删除,直到取消出售或交易完成买家侧:购买频道
// 🔒 SDK:channels.buyChannel(channelId)
// → POST /api/v1/channels/{channelId}/buy {}
// → ChannelTradeOrder { order_id, price_usdt, pay_to, expired_at }
//
// 👤 App:ChannelDetail 内,for_sale === true && role !== 'owner' 时显示:
// 「💰 购买此频道 - xxx USDT」按钮
// 点击后调用 buyChannel(),弹出支付弹窗(复用靓号支付 UI 模式):
// - 显示 TRON 收款地址 + 金额 + 倒计时
// - 轮询 GET /api/v1/vanity/order/{orderId}/status
// - status === 'COMPLETED' → 自动刷新页面,频道 role 变为 'owner'
//
// ⚠️ 频道交易成功后,所有权自动转移:
// - 新 Owner 获得发帖权限
// - 旧 Owner 降为普通订阅者
// - 频道 for_sale 自动关闭十二、SettingsTab.tsx — 完整功能
| 功能 | 🔒 SDK 做的 | 👤 App 做的 |
|---|---|---|
| 展示 AliasID | — | 读 localStorage sc_alias_id 展示 + 一键复制 |
| 加密算法 | — | 固定展示文字 X25519-AES-GCM |
| 查看助记词 | loadIdentity() 读 IndexedDB | 两步确认UI + grid 展示 |
| 靓号商店 | client.vanity.search(q?) / client.vanity.purchase(aliasId) / client.vanity.bind(orderId) | ✅ 已实现(onboarding/VanityShop.tsx);规则引擎架构:后端 vanity/rules.go 实时评估任意 8 位数字的等级和价格(top/premium/standard),无需预填表;VanityItem 返回 {alias_id, price_usdt, tier, is_featured};搜索 API 公开(无需 JWT);🔴 靓号绑定仅限 onboarding 阶段一次性完成,不支持换号——Settings 内不提供换号入口 |
| 离线推送 | client.push.enablePushNotifications() | Notification.requestPermission() 三态 UI |
| 存储管理 | — | GET /api/v1/storage/estimate → 展示统计 |
| 导出 | client.exportConversation('all') → Blob Object URL | <a download=".ndjson"> 触发下载 |
| 退出(核销) | clearIdentity() + client.clearAllHistory() | client.disconnect() + 清 localStorage + 重置 Zustand |
// ── 退出完整流程(顺序很重要)──
const handleLogout = async () => {
if (!confirm('清理本地身份将无法恢复。确认退出?')) return;
client.disconnect(); // 👤 App → SDK:主动断开 WS
await clearIdentity(); // 🔒 SDK(独立函数):清 IndexedDB identity store
await client.clearAllHistory(); // 🔒 SDK:清 IndexedDB messages + sessions store
// 👤 App:清 localStorage(SDK 不管这些字段)
localStorage.removeItem('sc_token');
localStorage.removeItem('sc_uuid');
localStorage.removeItem('sc_alias_id');
localStorage.removeItem('sc_nickname');
// 👤 App:重置 UI 状态
setUserInfo('', '', '');
setSdkReady(false);
setRoute('welcome');
};localStorage 键名完整清单(App 管理,SDK 不读取)
| Key | 说明 |
|---|---|
sc_alias_id | 用户 Alias ID(注册返回) |
sc_nickname | 用户昵称(UI 展示用) |
sc_token | JWT(SDK 内部也存了,这里冗余存一份供 MessagesTab 漫游补齐用) |
sc_uuid | 内部 UUID(注销时需要清除) |
十四、activeChatId 完整来源(4 个入口)
不自己生成,只从 SDK 返回值里读取。 参见"零、术语统一"。
【根源】 双方建立好友关系时,服务端唯一创建:
🔒 SDK: acceptFriendRequest(friendshipId: number)
→ PUT /friends/{id}/accept
→ 服务端返回 { conversation_id: "c-xxxxxxxx" }
→ SDK 以此为 key 做 ECDH,建立会话密钥,写入 IndexedDB入口 1:通讯录点击好友 → 发起聊天
// 来源:client.contacts.syncFriends() → ContactProfile.conversation_id
setActiveChatId(friend.conversation_id)入口 2:消息列表点击某个会话
// 来源:listSessions()(🔒 SDK 读 IndexedDB)→ SessionRecord.conversationId
setActiveChatId(s.conversationId)入口 3:冷启动时点击推送通知
// 🔴 F05.3 零知识推送:push payload 不含 conv_id,点击通知只唤醒 App
// ServiceWorker 的 notificationclick 直接 openWindow('/') 或 focus 现有窗口
// App 唤醒后 WS 重连 → SDK 自动 sync → 未读消息自动补齐
// 不再使用 /?chat=xxx URL 参数跳转特定会话入口 4:PWA 后台运行时被 ServiceWorker 唤醒
// 🔴 F05.3:ServiceWorker 不再发送 OPEN_CHAT 消息(因为没有 conversationId)
// 窗口被 focus 后,WS 心跳恢复 → 自动 sync 拉取未读消息社交护城河:
conversationId与社交关系绑定在当前 SDK 实例的服务端,不可跨平台迁移,这是产品的核心壁垒。
十五、分阶段开发执行顺序
⚠️ 所有 8 个阶段都是强制交付的,不可跳过任何一个。
| Phase | 交付产物 | 核心注意点 |
|---|---|---|
| 1 | vite.config.ts(含 PWA 插件)+ appStore.ts + imClient.ts + utils.ts | PWA manifest + SW 就绪;不要在 imClient 传参数给 SDK |
| 2 | App.tsx + Welcome + GenerateMnemonic + ConfirmBackup + SetNickname + VanityShop(注册后,可跳过) + Recover | 注册完跳 vanity_shop 而非 main;newMnemonic 是同步函数! |
| 3 | MainLayout.tsx + NetworkBanner.tsx + InstallBanner.tsx | PWA 安装引导横幅 + 三色网络状态 + 红点角标 |
| 4 | MessagesTab.tsx + ContactsTab.tsx + MyQRCodeDialog + ScanQRDialog | syncFriends 已自动建 ECDH;安全码核验蒙层必须实现;二维码+扫一扫必须实现 |
| 5 | ChannelsTab.tsx + ChannelDetail.tsx | canPost 是本地计算,无需发请求 |
| 6 | ChatWindow.tsx + ImageBubble + VoiceBubble + 录音 UI | sendImage / sendVoice / sendFile 三种多媒体;麦克风录音 MediaRecorder 必须实现 |
| 7 | SettingsTab.tsx | 退出必须按顺序:disconnect→clearIdentity→clearAllHistory→清localStorage |
| 8 | CallScreen.tsx + initCallModule | ⚠️ 必须实现,不可跳过!完整的来电/通话/挂断 UI |
十六、前端 UI 交互与工程健壮性底线约束 (UI Robustness Protocol)
由于本项目致力于“傻瓜式、高容错”的客户端体验(尤其针对移动 PWA 环境),以及高频依赖 Vibecoding(AI 直接生成组件),所有参与生成的 AI 必须严格遵守以下法则。 核心原则:不可以要求人类去小心翼翼地点击,而是要让 UI 层自动包裹防弹衣去包容任何粗暴点击。
绝对禁止“即点即溃的裸图标按钮” (No Fragile Naked SVGs)
- 凡是单个图标(如
<Check />,<Trash />)作为核心点击热区的按钮,必须在<svg>节点显式追加pointer-events-none以确保事件委托(Event Delegation)精准命中<button>层(解决 iOS/WebKit 的经典触控冒泡吞吃问题)。 - 必须通过
type="button"和cursor-pointer等样式明确其非表单交互属性。 - 热区尺寸(Padding)不得小于移动端最小标准(至少
p-3或保证等效于w-10 h-10的区域)。
- 凡是单个图标(如
异步交互强制并发锁止与错误闭环 (Mandatory Async Lock & Error Closure)
- 凡是在
onClick等事件中调用client.异步方法的地带,严禁裸奔await。 - 必须在组件体内部维护
isProcessing或专门使用封装好的 Hook。并在按钮上绑定disabled={isProcessing}阻止一切网络延迟导致的狂暴连点。 - SDK 是黑盒,绝不可在 SDK 内部吃掉错误。UI 层
try...catch捕捉到底层 SDK 的抛出错误后,必须以对终端用户明确的格式展现出来(例如将状态机的errorMessage渲染在按钮附近或以 Alert 形式),杜绝任何类型的“操作无反应(Silent Failure)”。
- 凡是在
复杂防穿透隔离机制
- 位于
.map()生成的嵌套卡片中用于触发非全屏切换的操作(例如在列表中同意加好友),其触发器内部的首行逻辑必须是e.preventDefault()与e.stopPropagation(),强制执行事件隔断。
- 位于
十八、端到端音视频通话(⚠️ 必须实现,不可跳过)
核心架构: 呼叫信令通过 WebSocket 半加密传输(路由字段明文,SDP/Candidate AES-GCM 加密)。 媒体流通过 P2P STUN/TURN (WebRTC) 传输,DTLS/SRTP 加密,服务端为纯盲转发。 🔴 这是 Phase 8 的强制交付产物。
18.1 Zustand Store 通话状态(加入 appStore.ts)
// ── 在 AppState interface 中新增以下字段 ──
callState: 'ringing' | 'calling' | 'connecting' | 'connected' | null;
callPeerAlias: string;
callType: 'audio' | 'video';
callDuration: number;
setCallState: (s: 'ringing' | 'calling' | 'connecting' | 'connected' | null) => void;
beginCall: (peerAlias: string, type: 'audio' | 'video') => void;
incrementCallDuration: () => void;
// ── 在 create() 实现中新增 ──
callState: null,
callPeerAlias: '',
callType: 'audio',
callDuration: 0,
setCallState: (callState) => set({ callState, callDuration: callState === null ? 0 : get().callDuration }),
beginCall: (callPeerAlias, callType) => set({ callPeerAlias, callType, callDuration: 0 }),
incrementCallDuration: () => set((s) => ({ callDuration: s.callDuration + 1 })),18.2 通话模块初始化 src/lib/callSetup.ts
import { loadIdentity, deriveIdentity } from '@daomessage_sdk/sdk';
import { client } from '@/lib/imClient';
import { useAppStore } from '@/store/appStore';
export const initCallModule = async (alwaysRelay = false) => {
if (client.calls) return;
const ident = await loadIdentity();
if (!ident) return;
const fullIdent = deriveIdentity(ident.mnemonic);
client.initCalls({
signingPrivKey: fullIdent.signingKey.privateKey,
signingPubKey: fullIdent.signingKey.publicKey,
myAliasId: ident.aliasId,
alwaysRelay,
});
client.calls!.onIncomingCall = (from, isVideo) => {
useAppStore.getState().beginCall(from, isVideo ? 'video' : 'audio');
useAppStore.getState().setCallState('ringing');
};
client.calls!.onCallEnded = () => {
useAppStore.getState().setCallState(null);
};
};
export const getCallModule = () => client.calls;⚠️ App.tsx 调用时机:
restoreSession成功 +initIMClient()之后立即调用await initCallModule();
18.3 ChatWindow 通话入口(⚠️ 必须有 📞/📹 按钮)
import { Phone, Video } from 'lucide-react';
import { getCallModule } from '@/lib/callSetup';
const handleAudioCall = async () => {
const mod = getCallModule();
if (!mod || !sessionInfo) return;
const store = useAppStore.getState();
store.beginCall(sessionInfo.theirAliasId, 'audio');
store.setCallState('calling');
try {
await mod.call(sessionInfo.theirAliasId, { audio: true, video: false });
} catch (e: any) {
store.setCallState(null);
alert(`语音呼叫失败: ${e?.message || '未知错误'}`);
}
};
// ⚠️ SDK 1.0.25+ 严格模式:getUserMedia(video) 失败不再降级到纯音频,会直接抛错。
// UI 必须 try/catch 并按错误类型给用户清晰提示,否则旧版"静默降级"会导致:
// 1. PWA 没弹摄像头权限框,用户以为没生效
// 2. SDP 仍含 m=video,对端以为是视频通话也开摄像头
// 3. 双方语义错位 — 对端能看到 PWA "画面"但 PWA 没在发 RTP 帧
const handleVideoCall = async () => {
const mod = getCallModule();
if (!mod || !sessionInfo) return;
const store = useAppStore.getState();
store.beginCall(sessionInfo.theirAliasId, 'video');
store.setCallState('calling');
try {
await mod.call(sessionInfo.theirAliasId, { audio: true, video: true });
} catch (e: any) {
store.setCallState(null);
const msg = String(e?.message || '');
let hint = '请重试';
if (/Permission|denied|NotAllowedError/i.test(msg)) {
hint = '请在浏览器地址栏左侧的权限图标里允许摄像头和麦克风';
} else if (/NotFoundError|DevicesNotFound/i.test(msg)) {
hint = '没有检测到摄像头设备,请确认设备已连接';
} else if (/NotReadableError|TrackStartError/i.test(msg)) {
hint = '摄像头被其他应用占用,请关闭其他视频软件后重试';
} else if (/timeout/i.test(msg)) {
hint = '摄像头响应超时,请检查浏览器是否被禁用了媒体权限';
}
alert(`视频呼叫失败:${hint}\n\n详细错误:${msg || '未知'}`);
}
};
// ChatWindow 顶栏右侧按钮
<button onClick={handleAudioCall} className="p-2 text-zinc-400 hover:text-white">
<Phone className="w-5 h-5 pointer-events-none" />
</button>
<button onClick={handleVideoCall} className="p-2 text-zinc-400 hover:text-white">
<Video className="w-5 h-5 pointer-events-none" />
</button>18.4 CallScreen.tsx — 完整通话界面
import { useState, useEffect, useRef } from 'react';
import { Phone, PhoneOff, Mic, MicOff, Video, VideoOff } from 'lucide-react';
import { useAppStore } from '@/store/appStore';
import { getCallModule } from '@/lib/callSetup';
export function CallScreen() {
const { callState, callPeerAlias, callType, callDuration,
setCallState, incrementCallDuration } = useAppStore();
const [isMuted, setIsMuted] = useState(false);
const [isCameraOff, setIsCameraOff] = useState(false);
const remoteVideoRef = useRef<HTMLVideoElement>(null);
const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteAudioRef = useRef<HTMLAudioElement>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isVideo = callType === 'video';
// 通话计时器
useEffect(() => {
if (callState === 'connected') {
timerRef.current = setInterval(() => incrementCallDuration(), 1000);
}
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [callState]);
// 绑定远端媒体流
useEffect(() => {
if (callState !== 'connected') return;
const mod = getCallModule();
if (!mod) return;
const remote = mod.getRemoteStream();
if (remote) {
if (isVideo && remoteVideoRef.current) {
remoteVideoRef.current.srcObject = remote;
remoteVideoRef.current.play().catch(console.error);
}
if (remoteAudioRef.current) {
remoteAudioRef.current.srcObject = remote;
remoteAudioRef.current.play().catch(console.error);
}
}
const local = mod.getLocalStream();
if (local && isVideo && localVideoRef.current) {
localVideoRef.current.srcObject = local;
localVideoRef.current.play().catch(console.error);
}
}, [callState, isVideo]);
if (!callState) return null;
const fmt = (s: number) =>
`${Math.floor(s / 60).toString().padStart(2, '0')}:${(s % 60).toString().padStart(2, '0')}`;
const handleAnswer = async () => { setCallState('connecting'); await getCallModule()?.answer(); };
const handleHangup = () => { getCallModule()?.hangup(); setCallState(null); };
const toggleMute = () => {
getCallModule()?.getLocalStream()?.getAudioTracks().forEach(t => { t.enabled = !t.enabled; });
setIsMuted(!isMuted);
};
const toggleCamera = () => {
getCallModule()?.getLocalStream()?.getVideoTracks().forEach(t => { t.enabled = !t.enabled; });
setIsCameraOff(!isCameraOff);
};
return (
<div className="fixed inset-0 z-[100] bg-zinc-950 flex flex-col items-center justify-between text-white">
{/* 🚨 远端音频 — 绝对禁止 display:none,iOS 会截断音频流 */}
<audio ref={remoteAudioRef} autoPlay playsInline
className="absolute w-0 h-0 opacity-0 pointer-events-none" />
{/* 视频画面 */}
{isVideo && callState === 'connected' && (<>
<video ref={remoteVideoRef} autoPlay playsInline
className="absolute inset-0 w-full h-full object-cover" />
<video ref={localVideoRef} autoPlay playsInline muted
className="absolute top-4 right-4 w-32 h-44 object-cover rounded-2xl border-2 border-zinc-700 z-10" />
</>)}
{/* 顶部信息 */}
<div className="relative z-20 pt-16 text-center">
<div className="w-20 h-20 mx-auto rounded-full bg-zinc-800 flex items-center justify-center text-2xl font-bold mb-4">
{callPeerAlias.slice(0, 2).toUpperCase()}
</div>
<h2 className="text-xl font-bold">{callPeerAlias}</h2>
<p className="text-sm text-zinc-400 mt-1">
{callState === 'ringing' && 'Incoming call...'}
{callState === 'calling' && 'Calling...'}
{callState === 'connecting' && 'Connecting...'}
{callState === 'connected' && fmt(callDuration)}
</p>
</div>
{/* 底部操作按钮 */}
<div className="relative z-20 pb-16 flex items-center gap-6">
{callState === 'ringing' && (<>
<button onClick={handleHangup}
className="w-16 h-16 rounded-full bg-red-500 flex items-center justify-center">
<PhoneOff className="w-7 h-7 text-white pointer-events-none" />
</button>
<button onClick={handleAnswer}
className="w-16 h-16 rounded-full bg-green-500 flex items-center justify-center animate-pulse">
<Phone className="w-7 h-7 text-white pointer-events-none" />
</button>
</>)}
{(callState === 'calling' || callState === 'connecting') && (
<button onClick={handleHangup}
className="w-16 h-16 rounded-full bg-red-500 flex items-center justify-center">
<PhoneOff className="w-7 h-7 text-white pointer-events-none" />
</button>
)}
{callState === 'connected' && (<>
<button onClick={toggleMute}
className={`w-14 h-14 rounded-full flex items-center justify-center ${isMuted ? 'bg-red-500/30' : 'bg-zinc-700'}`}>
{isMuted ? <MicOff className="w-6 h-6 text-red-400 pointer-events-none" />
: <Mic className="w-6 h-6 text-white pointer-events-none" />}
</button>
{isVideo && (
<button onClick={toggleCamera}
className={`w-14 h-14 rounded-full flex items-center justify-center ${isCameraOff ? 'bg-red-500/30' : 'bg-zinc-700'}`}>
{isCameraOff ? <VideoOff className="w-6 h-6 text-red-400 pointer-events-none" />
: <Video className="w-6 h-6 text-white pointer-events-none" />}
</button>
)}
<button onClick={handleHangup}
className="w-16 h-16 rounded-full bg-red-500 flex items-center justify-center">
<PhoneOff className="w-7 h-7 text-white pointer-events-none" />
</button>
</>)}
</div>
</div>
);
}⚠️ App.tsx 全局渲染:
tsx<> <CallScreen /> {route === 'welcome' && <Welcome />} ... </>
18.4.1 通话生命周期与终止态文案(1.0.29+,1.0.30 加固)
SDK 内部新增三组生命周期保障(无需配置,自动生效):
| 场景 | 触发 | 自动动作 |
|---|---|---|
| 对端断网 | RTCPeerConnection.connectionState === 'disconnected' 持续 8 秒 | SDK 自动 cleanup('ended'),触发 state_change → 'ended' |
| 对端 App 被杀 | 接通后入站 RTP bytesReceived / framesDecoded 静默 15 秒 | SDK 自动 cleanup('ended') |
| 对端不接听 (1.0.30+) | 主叫等 answer 超时 15 秒 | SDK 自动先发 call_hangup 通知对端再 cleanup,杜绝"幽灵接通"(对端来电界面立即消失) |
| 正常挂断 | 双端任一调 hangup() 或对端发 call_hangup 信令 | 立即 cleanup('hangup'/'ended') |
UI 必须区分 3 种终止态文案(对应 spec call-lifecycle / Requirement: UI 计时器与终止状态联动):
const STATE_LABELS: Record<string, string> = {
// ... 既有的 calling/ringing/connecting/connected
hangup: '通话已结束', // 主动挂断 / 收到对端 call_hangup
rejected: '对方拒绝了通话', // 对端拒接
ended: '连接已断开', // SDK 自动兜底触发(网断/对方崩了)
};计时器逻辑必须正确:
// ❌ 错的:终止态 reset 0,用户看不到最终时长
useEffect(() => {
if (callState !== 'connected') { setDurationSec(0); return; }
const id = setInterval(() => setDurationSec(s => s + 1), 1000);
return () => clearInterval(id);
}, [callState]);
// ✅ 对的:终止态保留 durationSec,新通话开始时再 reset
useEffect(() => {
if (callState === 'connected') {
const id = setInterval(() => setDurationSec(s => s + 1), 1000);
return () => clearInterval(id);
}
if (['calling', 'ringing', 'idle', null].includes(callState as string)) {
setDurationSec(0)
}
// 终止态 hangup/rejected/ended:不动 durationSec,UI 显示最终时长
}, [callState]);
// 终止态后 1.5s 自动退出页面(让用户看到"02:35 · 通话已结束"再消失)
useEffect(() => {
if (callState && ['hangup','rejected','ended'].includes(callState)) {
const id = setTimeout(() => setCallState(null), 1500)
return () => clearTimeout(id)
}
}, [callState]);18.4.2 视频画质底线(1.0.29+)
SDK 1.0.29+ 默认开启:1280×720 ideal · 30fps · maxBitrate 1.5Mbps。
注:
setCodecPreferences(VP9>H264>VP8) 本版未实施,codec 由浏览器/Android 默认协商决定。计划下次单独发版。
无需用户配置。验证方法:接通后 console 每 2s 自动打印 📊 [Diag #N] 行,稳定后应满足:
frame=1280x720或不低于 540pfps >= 24qLimit=none或qLimit=bandwidth(不应是cpu)
18.5 🚨 致命报错速查
| 症状 | 根因 | 修复 |
|---|---|---|
| 语音通话没声音 | <audio> 使用了 display:none / hidden | 改用 absolute w-0 h-0 opacity-0 |
remote description was null | ICE 先于 SDP 到达 | SDK 内部已缓冲,开发者免管 |
| 视频不播放 | .play() 未 .catch() | 所有 .play() 必须 .catch(console.error) |
| 来电不响铃 | initCallModule 未调用 | App.tsx restoreSession 后立即调用 |
| 「对方看不到我的视频」 | 绝大多数情况是误判;摄像头物理上对着深色物体 / OBS 软件,你看到的"黑屏"是真实视频帧 | 先看 webrtc-internals 的 outbound-rtp video bytesSent/framesEncoded 是否在涨,涨 = 你这边在发,问题不在你。SDK 1.0.27+ 通话连上后 console 每 2s 自动打印 📊 [Diag] 行,直接看数字 |
| 「我看不到对方的视频」 | 同上,先确认是真黑屏还是远端摄像头视野很暗 | 看 inbound-rtp video bytesReceived/framesDecoded 是否在涨。涨 = 你在收,屏幕上显示的就是远端真实画面 |
| 视频通话单向(只能一方看到另一方) | 主叫端 SDP m=video 方向被 Chrome 设成 sendonly | SDK 1.0.26+ 自动调 forceSendrecvOnSdp 双端修方向,升级 SDK 即可 |
| 点视频按钮浏览器不弹摄像头权限 | 旧版 SDK gUM 失败静默降级到 audio | SDK 1.0.25+ 严格模式 + UI try/catch 弹错(见 18.3 章) |
| 主叫卡在"建立中"超过 15s | 对端信令丢失 / 网络抖动 | SDK 1.0.24+ 自动 15s 超时 + ICE restart 自愈 |
十九、视觉一致性铁律(三端同款品牌)
任何 UI 改动前必须先看
docs/DESIGN_TOKENS.md。 三端 (PWA / Android / iOS) 同步实现这套 tokens,改一端必须考虑另两端。
19.1 颜色禁区
❌ 禁止硬编码颜色 — 不允许 text-[#3B82F6] bg-[rgb(59,130,246)] ✅ 只能使用 Tailwind class 引用 tokens:
| 类型 | 必须用 | 禁止 |
|---|---|---|
| 背景 base | bg-zinc-950 | bg-black bg-[#000] |
| 背景 surface | bg-zinc-900 | bg-gray-900 bg-neutral-900 |
| 背景 raised | bg-zinc-800 | 同上 |
| 文字主 | text-zinc-50 或 text-white | text-[#fff] |
| 文字次 | text-zinc-400 | text-gray-400 |
| 品牌主色 | bg-blue-500 | bg-blue-600/700/400 都不对 |
| 品牌 hover | hover:bg-blue-600 | 其他色阶 |
| 错误 | bg-red-500 | red-600/400 |
| 成功 | bg-green-500 或 text-green-400(E2EE 徽章) | green-600 |
| 警告 | bg-amber-400 | yellow-* |
19.2 间距铁律(4px 网格)
只用 p-1 p-2 p-3 p-4 p-5 p-6 p-8 p-10,不允许 p-[5px] p-[13px] 等魔数。
19.3 圆角白名单
| Tailwind | px | 用途 |
|---|---|---|
rounded | 4 | 小标签 |
rounded-md | 6 | — |
rounded-lg | 8 | 按钮 / 表单输入框 |
rounded-xl | 12 | 卡片 |
rounded-2xl | 16 | 大卡片 / 对话气泡 / 聊天输入框 (pill) |
rounded-3xl | 24 | 特殊容器 |
rounded-full | — | 头像 / 圆按钮 |
19.4 标准组件(必须用,禁自己造同名)
// ✅ 用我们的统一 Button (src/components/ui/Button.tsx)
import { Button } from '@/components/ui/Button';
<Button variant="primary" onClick={fn}>创建账户</Button>
// ✅ 用我们的统一 Avatar
import { Avatar } from '@/components/ui/Avatar';
<Avatar text={aliasId} size="md" />
// ❌ 不允许自己写
<button className="bg-blue-600 px-4 py-2 ..."> // ← 不对齐 tokens19.5 关键尺寸(三端共识,不许漂移)
| 元素 | 高 | 圆角 | 字号 |
|---|---|---|---|
| Primary Button | 48 | radius.lg (8) | text-base (16) |
| Form Input | 48 | radius.lg | text-sm (14) |
| Chat Input (pill) | 44 | radius.2xl (16) | text-sm |
| TabBar | 56 | — | text-xs (12) |
| TopBar | 56 | — | text-lg (18) |
| MessageBubble | auto | radius.2xl + 4px 尖角 | text-sm |
| Avatar sm/md/lg/xl | 32/48/80/96 | radius.full | size × 0.4 |
19.6 三端必须保持一致的
- 主品牌渐变:
linear(135deg, #3B82F6, #8B5CF6, #A855F7)(blue-500 → violet-500 → purple-500) - 标题渐变(Welcome 等):
linear(blue-400, violet-400, purple-400)浅一档 - 消息气泡尖角:自己消息右下尖 / 对方消息左下尖(不是上方)
- 端到端加密徽章:
text-green-400+border-green-500/40+bg-green-500/10 - 网络状态横幅:断网 =
bg-red-500/ 重连 =bg-amber-400/ 恢复 =bg-green-500
19.7 验证(组件库展示页)
部署后访问 /components.html 查看所有组件标准样式作为对照基线。 跑视觉回归:
cd visual-tests && npm run test:pwasnapshot 对不上 = 破坏了 token 对齐。要么改回去,要么 update DESIGN_TOKENS.md + 三端同步 + 重录基线。