Skip to content

💡 将本文全文复制后粘贴给 AI,即可从零构建 DAO MESSAGE 客户端

DAO MESSAGE — Vibecoding 建造指南

使用说明: 将本文档全文粘贴给 AI,说"请按照这份指南从零构建工程"即可。
本文由三部分组成:架构原则 + SDK API 参考(机器可读) + 接线示例


Part A: 架构原则(10 条铁律)

DAO MESSAGE 不是一个 App,而是一套 AI 时代的隐私通讯协议 + 中继网络即服务。 你正在帮用户构建的,是一个基于 DAO MESSAGE 协议的专属聊天客户端

SDK 安装

bash
npm install @daomessage_sdk/sdk

SDK 已发布到 npm 公共仓库,直接 npm install 即可。无需本地 link、submodule 或 monorepo 配置。 源码仓库:https://github.com/daomessage/securechat-sdk-typescript

#铁律约束
1SDK 是黑盒@daomessage_sdk/sdk 处理所有加密、密钥、WebSocket、IndexedDB。App 只做 UI 壳。不要重复实现 SDK 已有的任何功能
2协议至上所有客户端共享同一套 WS 帧格式和加密协议,你不能自创私有协议
3绝对零信任服务端只转发密文,App 层不得发送任何明文到服务端
4API 已硬编码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 默认英文
10Vibe Coding 生态你生成的 App 必须通过 SDK 接入官方中继,与其他开发者的 App 互通

技术栈(强制)

技术版本
构建工具Vite6+
前端框架React19+
样式方案TailwindCSS + @tailwindcss/vite 插件v4(不是 v3)
状态管理Zustand5+
路由React Router7+
语言TypeScript(strict mode)5.5+

构建优先级

按以下顺序实现,每个阶段完成可运行后再进入下一阶段:

  1. 工程骨架 — Vite 初始化 + TailwindCSS v4 + 路由 + SDK 单例
  2. 注册/登录 — 助记词生成 + 备份确认 + 注册 + 恢复会话
  3. 通讯录 — 好友列表 + 添加好友 + 好友请求处理
  4. 文字聊天 — 消息收发 + 历史记录 + 已读回执 + 正在输入
  5. 多媒体 — 图片/文件/语音消息
  6. 频道 — 频道列表 + 订阅 + 发帖
  7. 音视频通话 — WebRTC 信令 + E2EE 帧加密(最后实现)

Part B: SDK TypeScript 类型声明(机器可读合约)

以下是 @daomessage_sdk/sdk完整 .d.ts 类型声明文件。 AI 必须严格按照这些类型签名调用 SDK,不存在的方法不能调用。 这是单一真相源——如果本文任何其他描述与此 .d.ts 冲突,以 .d.ts 为准。

typescript
// ═══════════════════════════════════════════════
// @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;
/**
 * 计算 60 字符安全码
 * 算法:SHA-256(min(pubA, pubB) ‖ max(pubA, pubB))[0..30] → hex
 * 双方使用相同的确定性拼接顺序,MITM 无法伪造一致结果
 */
declare function computeSecurityCode(myEcdhPublicKey: Uint8Array, theirEcdhPublicKey: Uint8Array): string;
declare function toBase64(bytes: Uint8Array): string;
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;
}
interface SessionRecord {
    conversationId: string;
    theirAliasId: string;
    theirEcdhPublicKey: string;
    theirEd25519PublicKey?: string;
    sessionKeyBase64: string;
    trustState: 'unverified' | 'verified';
    createdAt: number;
}
interface OfflineMessage {
    conversationId: string;
    seq: number;
    payloadEncrypted: string;
    createdAt: 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>;

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;
    constructor();
    onNetworkStateChange(fn: NetworkListener): () => void;
    /** 监听 GOAWAY 帧(被其他设备踢下线) */
    onGoaway(fn: (reason: string) => void): () => void;
    private emitNetworkState;
    connect(url: 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;
    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;
    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;
    constructor(http: HttpClient);
    /**
     * 供 SDK 内部建立 WebSocket 时调用,防误用
     */
    get internalUUID(): string;
    /**
     * 恢复会话:从本地 IndexedDB 加载身份 -> 防伪签名挑战 -> 写入 Token
     */
    restoreSession(): 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>;
}

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 http;
    constructor(http: HttpClient);
    /**
     * 同步通讯录:获取所有好友,并为已经接受的好友自动创建本地安全会话(按需)
     */
    syncFriends(): Promise<FriendProfile[]>;
    /**
     * 发起好友请求
     */
    sendFriendRequest(toAliasId: string): Promise<void>;
    /**
     * 接受好友请求
     */
    acceptFriendRequest(friendshipId: number): Promise<void>;
    /**
     * 按 Alias ID 查找用户
     */
    lookupUser(aliasId: string): Promise<{
        alias_id: string;
        nickname: string;
        x25519_public_key: string;
        ed25519_public_key: string;
    }>;
}

/**
 * sdk-typescript/src/media/manager.ts — 多媒体上传/下载(零知识盲中转)
 * 支持图片(压缩)、文件(原始)、语音(原始)三类
 */

declare class MediaModule {
    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;
}

declare class PushModule {
    private http;
    constructor(http: HttpClient);
    /**
     * 浏览器申请推送凭证并向服务端注册
     * 此方法需要依赖浏览器的 ServiceWorker API,仅在 Web 端有效
     */
    enablePushNotifications(swRegistration: ServiceWorkerRegistration, vapidPublicKey?: string): Promise<void>;
    private urlBase64ToUint8Array;
}

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;
}

/**
 * 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 flushIceCandidates;
    private signingPrivKey;
    private signingPubKey;
    private myAliasId;
    onStateChange?: (state: CallState) => void;
    onRemoteStream?: (stream: MediaStream) => void;
    onLocalStream?: (stream: MediaStream) => void;
    onIncomingCall?: (fromAlias: string) => 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>;
    answer(): Promise<void>;
    reject(): void;
    hangup(): void;
    private _callerAlias;
    private _remoteAlias;
    private handleSignal;
    private handleOffer;
    private handleAnswer;
    private handleICE;
    private createPeerConnection;
    private sendSignal;
    private setState;
    private cleanup;
    private _pubKeyCache;
    private fetchPubKey;
    private _sessionKeyCache;
    private getSessionKey;
}
/**
 * setupE2EETransform:为 RTCRtpSender/Receiver 安装帧级加解密 Transform
 * @param kind         'sender' | 'receiver'
 * @param rtpObject    RTCRtpSender 或 RTCRtpReceiver
 * @param keyMaterial  AES-256-GCM 密钥 + BaseIV(44字节:32+12,由 HKDF 从会话密钥派生)
 */
declare function setupE2EETransform(kind: 'sender' | 'receiver', rtpObject: RTCRtpSender | RTCRtpReceiver, keyMaterial: Uint8Array): Promise<void>;

type ClientEvent = 'message' | 'status_change' | 'network_state' | 'channel_post' | 'typing' | 'goaway';
interface TypingEvent {
    fromAliasId: string;
    conversationId: string;
}
declare class SecureChatClient {
    readonly transport: RobustWSTransport;
    readonly messaging: MessageModule;
    readonly auth: AuthModule;
    readonly contacts: ContactsModule;
    readonly media: MediaModule;
    readonly push: PushModule;
    readonly channels: ChannelsModule;
    readonly vanity: VanityModule;
    calls: CallModule | null;
    http: HttpClient;
    private eventListeners;
    static readonly CORE_API_BASE = "https://relay.daomessage.com";
    constructor();
    /**
     * 恢复历史会话:从本地加载身份鉴权,成功后即可调用 connect
     * 返回值新增 nickname,App 不再需要从 localStorage 读取昵称
     */
    restoreSession(): Promise<{
        aliasId: string;
        nickname: string;
    } | null>;
    /**
     * 建立连接:自动从 AuthModule 与 HTTP 获取底层验证标识,封装并连接 WSS
     */
    connect(): void;
    /**
     * 断开连接
     */
    disconnect(): void;
    /**
     * 获取当前网络状态
     */
    get isConnected(): boolean;
    /**
     * 初始化通话模块(需要提供从 DB 提取的用户身份密钥)
     */
    initCalls(opts: {
        signingPrivKey: Uint8Array;
        signingPubKey: Uint8Array;
        myAliasId: string;
        alwaysRelay?: boolean;
    }): void;
    /**
     * 事件订阅 — 返回 unsubscribe 函数,可直接在 useEffect return 中调用
     * @example
     * useEffect(() => {
     *   return client.on('message', handleMsg)  // 自动解绑
     * }, [])
     */
    on(event: 'message', listener: (msg: StoredMessage) => void): () => void;
    on(event: 'status_change', listener: (status: MessageStatus) => void): () => void;
    on(event: 'network_state', listener: (state: NetworkState) => void): () => void;
    on(event: 'channel_post', listener: (data: any) => void): () => void;
    on(event: 'typing', listener: (data: TypingEvent) => void): () => void;
    on(event: 'goaway', listener: (reason: string) => void): () => void;
    /**
     * 移除事件订阅(on() 已返回 unsubscribe,推荐用 on() 的返回值代替此方法)
     */
    off(event: ClientEvent, listener: any): void;
    /**
     * 发送端到端加密消息(自动入队或发送)
     * @param replyToId 可选,引用回复的原消息 ID(架构 §SendOptions.replyTo)
     */
    sendMessage(conversationId: string, toAliasId: string, text: string, replyToId?: string): Promise<string>;
    /**
     * 发送图片消息:压缩、盲加密上传、拼接协议发回
     * @param thumbnail 可选,Base64 低分辨率骨架缩略图,供接收方在高清图加载前展示
     */
    sendImage(conversationId: string, toAliasId: string, file: File, thumbnail?: string): Promise<string>;
    /**
     * 发送文件消息:直接加密上传(不压缩)
     * 消息协议: [file]media_key|filename|size
     */
    sendFile(conversationId: string, toAliasId: string, file: File): Promise<string>;
    /**
     * 发送语音消息:录音 Blob 直接加密上传
     * @param durationMs 录音时长(毫秒),前端 MediaRecorder 计时
     * 消息协议: [voice]media_key|duration_ms
     */
    sendVoice(conversationId: string, toAliasId: string, blob: Blob, durationMs: number): Promise<string>;
    /**
     * 发送正在输入状态(含节流,建议调用方在 300ms 防抖后触发)
     */
    sendTyping(conversationId: string, toAliasId: string): void;
    /**
     * 标记收到的消息为已读
     * @param toAliasId 消息发送方的 alias_id,后端据此路由已读回执
     */
    markAsRead(conversationId: string, maxSeq: number, toAliasId: string): void;
    /**
     * 撤回消息(架构 §4.2 V1.1 新增)
     * 仅可撤回自己发的消息,不限时间
     * @param messageId 要撤回的消息 UUID
     * @param toAliasId 对方 alias_id
     * @param conversationId 会话 ID
     */
    retractMessage(messageId: string, toAliasId: string, conversationId: string): Promise<void>;
    /**
     * 获取会话的历史消息(来自 SDK 内部持久化数据库 IndexedDB)
     * @param opts.limit   最多返回条数(默认全量)
     * @param opts.before  只返回时间戳小于此值的消息(用于分页加载更早消息)
     */
    getHistory(conversationId: string, opts?: {
        limit?: number;
        before?: number;
    }): Promise<StoredMessage[]>;
    /**
     * 获取单条消息细节
     */
    getMessageData(messageId: string): Promise<StoredMessage | undefined>;
    /**
     * 清除某个会话历史
     */
    clearHistory(conversationId: string): Promise<void>;
    /**
     * 清除所有会话历史
     */
    clearAllHistory(): Promise<void>;
    /**
     * 导出会话存档(NDJSON 格式)
     * @param conversationId 指定会话 ID,可传 'all' 导出全部
     * @returns string 生成下载用途的 Blob Object URL
     */
    exportConversation(conversationId: string): Promise<string>;
}

/**
 * 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>;
}
declare const securityModule: SecurityModule;

interface EstablishedSession {
    conversationId: string;
    securityCode: string;
    trustedAt?: number;
}

interface MessageEnvelope {
    type: 'msg';
    id: string;
    to: string;
    conv_id: string;
    crypto_v: number;
    payload: string;
}

export { CallModule, type CallOptions, type CallState, type ChannelTradeOrder, type FriendProfile as ContactProfile, ContactsModule, type EstablishedSession, type Identity, type KeyPair, type MessageEnvelope, MessageModule, type MessageStatus, type NetworkState, type OfflineMessage, type OrderStatus, type OutboxIntent, type OutgoingMessage, type PaymentConfirmedEvent, type PurchaseOrder, type ReserveOrder, RobustWSTransport, SecureChatClient, type SecurityCode, SecurityModule, type SecurityViolationEvent, type SessionRecord, type SignalTransport, type StoredIdentity, type StoredMessage, type TrustState, type TypingEvent, type VanityItem, VanityModule, type WSTransport, clearIdentity, computeSecurityCode, deleteSession, deriveIdentity, fromBase64, fromHex, listSessions, loadIdentity, loadSession, markSessionVerified, newMnemonic, securityModule, setupE2EETransform, toBase64, toHex, validateMnemonicWords };

Part C: 最小可运行接线示例

以下是将 SDK 接入 App 的关键接线代码。 AI 应以此为模式参考,扩展出完整的聊天应用。

C.1 SDK 单例(全局一个实例)

typescript
import { SecureChatClient } from '@daomessage_sdk/sdk';

// 无参实例化——API 地址已硬编码在 SDK 内
export const client = new SecureChatClient();

C.2 冷启动流程

typescript
// App 启动时 → 尝试恢复会话
const session = await client.restoreSession();

if (!session) {
  // 首次使用 → 进入注册流程
  navigateTo('welcome');
} else {
  // 已注册 → 连接 WebSocket + 同步好友
  const { aliasId, nickname } = session;
  client.connect();
  await client.contacts.syncFriends();
  navigateTo('main');
}

C.3 注册流程

typescript
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();

C.4 事件订阅(React 模式)

typescript
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 好友列表 + 分类

typescript
// 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 发消息 + 收消息

typescript
// 发文本
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 退出登录(顺序很重要)

typescript
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 多端踢出处理

typescript
// 必须监听!不处理会导致多设备冲突
useEffect(() => client.on('goaway', async (reason) => {
  client.disconnect();
  await clearIdentity();
  await client.clearAllHistory();
  localStorage.clear();
  navigateTo('welcome');
}), []);

Part D: Web/React (TypeScript) 实现指南

一、工程初始化

bash
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

src/lib/utils.ts

typescript
import clsx, { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }

⚠️ AI 强制约定:React 编码红线(违反必出 Bug)

❌ 禁止:将子组件/辅助组件定义在父组件函数体内部
✅ 正确:所有可复用的组件必须定义在模块级别(组件函数外部)

反面例子(禁止这样写):

tsx
function ContactsTab() {
  // ❌ 错误:每次父组件渲染,SectionHeader 都是新函数引用
  //    React 会把其下的整个子树卸载重建,导致子节点 onClick 丢失
  const SectionHeader = ({ title }) => <div>{title}</div>;
  return <SectionHeader title="好友" />;
}

正确写法:

tsx
// ✅ 正确:模块级定义,函数引用稳定,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>。

二、全局状态 src/store/appStore.ts

👤 App 实现 — SDK 不管 UI 状态,全部在 Zustand 管理

typescript
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 内)

typescript
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() {
  client.connect(); // 无参数,SDK 自行取身份
}

// ── 事件总线(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

责任说明:
🔒 SDKrestoreSession() — 读 IndexedDB → Ed25519 签名挑战 → 获取 JWT Token
👤 App:拿到返回值后更新 Zustand store,处理 URL Deep Link,调用 initIMClient()

typescript
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() 是同步函数(无返回值),不可 .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:监听事件 → 弹出全屏警告 → 用户确认后清除本地身份

typescript
// 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 — 入口

typescript
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'); // 👤 App

GenerateMnemonic.tsx — 展示并备份助记词

typescript
// 👤 App:展示 + 备份交互
// 助记词已在 Welcome 生成并存入 Zustand store,这里直接读 tempMnemonic
const words = tempMnemonic.split(' '); // string[12],UI 用 grid 3列展示

// 需要:一键复制按钮 + 勾选框"我已安全备份"(两者都满足才能点"下一步")
// 点"下一步" → setRoute('set_nickname')(不要在这一步调任何 SDK 方法)

Recover.tsx — 用助记词恢复账户

typescript
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 — 注册全链路(最关键)

typescript
const handleComplete = async () => {
  const { client, initIMClient } = await import('../../lib/imClient');

  // ──── 🔒 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 不管昵称,由 App 存到 localStorage
  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);
  setRoute('main');
};

六、MainLayout.tsx

typescript
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 — 三色网络状态

typescript
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 — 消息列表

🔒 SDKlistSessions() 读 IndexedDB sessions store;client.getHistory() 读消息记录
👤 App:服务端补齐(漫游)、排序、时间格式化、预览格式化、PWA横幅检测

typescript
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 新增)

typescript
// 👤 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 新增)

🔒 SDKclient.clearHistory(convId) 清除消息 + deleteSession(convId) 删除会话记录
👤 App:长按/右键菜单 + 确认弹窗

typescript
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 必须严格按此字段名编写)

typescript
// 来自 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()` 刷新列表 |

---

## 十、`ChatWindow.tsx` — 聊天室

### SDK 精确类型定义(摘自 `@daomessage_sdk/sdk` 源码,AI 必须严格按此字段名编写)

```typescript
// 来自 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 初始化(加载历史 + 安全码 + 实时监听 + 分页)

typescript
// 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 监听滚动到顶部

typescript
// 👤 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 发送消息

typescript
// 发送文本
// 🔒 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 安全码核验弹窗

typescript
// 👤 App:UI 门禁蒙层设计
// 未验证时(trustVerified === false):输入框上方加半透明蒙层
// 点击蒙层 → 打开安全码核验 Modal
// Modal 内容:
//   - 展示 60 位 securityCode(按 4 个字符一组渲染)
//   - 展示我方公钥前 30 位 hex(fromBase64(ident.ecdhPublicKey))
//   - 展示对方公钥前 30 位(sessionInfo.theirEcdhPublicKey 前 44 字符代表前 32 字节)

const handleMarkVerified = async () => {
  const targetCode = securityCode.slice(0,8).toLowerCase();
  const cleanInput = inputCode.trim().toLowerCase();
  if (cleanInput.length < 8) { setVerifyError('length'); return; }
  
  // 👤 App:比对前 8 位(足够防 MITM,完整比对推荐引导用户导出安全二维码)
  if (cleanInput.slice(0,8) === targetCode) {
    // 🔒 SDK(独立函数):markSessionVerified → 更新 IndexedDB sessions store
    await markSessionVerified(activeChatId!);
    // SDK 提供更强版本:securityModule.markAsVerified(contactId, myPub, theirPub)
    setTrustVerified(true);
    setShowSecurityModal(false);
  } else {
    setVerifyError('mismatch'); // ❌ 红色警告:"可能存在中间人攻击!"
  }
};

10.4 ImageBubble 组件(骨架屏 + Lightbox)

typescript
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 新增 filevoice 消息类型

消息 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 自动处理
typescript
// 👤 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 新增):

typescript
// 🔒 SDK:文件发送(不压缩,直接 AES-GCM 分片加密上传)
await client.sendFile(conversationId, toAliasId, file)

// 🔒 SDK:语音发送(录音 Blob 直接加密上传)
await client.sendVoice(conversationId, toAliasId, audioBlob, durationMs)

10.6 消息撤回(V1.1 新增)

🔒 SDKclient.retractMessage() 内部自动发送 {type:'retract'} WS 帧 + 本地替换为 msgType:'retracted' 系统消息
👤 App:仅负责 UI 触发入口(右键菜单「撤回」按钮)+ 撤回消息渲染

typescript
// ── 发起撤回 ──
// 🔒 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 新增)

🔒 SDKsend() 现支持 replyToId 可选参数,内部自动 JSON 包裹 {text, replyToId} 后加密
👤 App:维护 replyTo 状态、渲染预览条和引用卡片

typescript
// ── 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 交互方式

typescript
// ── 组件状态 ──
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 字段直接渲染

typescript
// ── 状态 ──
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">
            {{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>
)}

十一、频道系统

SDK 精确类型定义(摘自 @daomessage_sdk/sdk 源码,AI 必须严格按此字段名编写)

typescript
// 来自 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

typescript
// 🔒 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

typescript
// 🔒 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 侧:挂牌出售

typescript
// 🔒 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"
//   ⚠️ 挂牌后频道不可删除,直到取消出售或交易完成

买家侧:购买频道

typescript
// 🔒 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
typescript
// ── 退出完整流程(顺序很重要)──
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_tokenJWT(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:通讯录点击好友 → 发起聊天

typescript
// 来源:client.contacts.syncFriends() → ContactProfile.conversation_id
setActiveChatId(friend.conversation_id)

入口 2:消息列表点击某个会话

typescript
// 来源:listSessions()(🔒 SDK 读 IndexedDB)→ SessionRecord.conversationId
setActiveChatId(s.conversationId)

入口 3:冷启动时点击推送通知

typescript
// 🔴 F05.3 零知识推送:push payload 不含 conv_id,点击通知只唤醒 App
// ServiceWorker 的 notificationclick 直接 openWindow('/') 或 focus 现有窗口
// App 唤醒后 WS 重连 → SDK 自动 sync → 未读消息自动补齐
// 不再使用 /?chat=xxx URL 参数跳转特定会话

入口 4:PWA 后台运行时被 ServiceWorker 唤醒

typescript
// 🔴 F05.3:ServiceWorker 不再发送 OPEN_CHAT 消息(因为没有 conversationId)
// 窗口被 focus 后,WS 心跳恢复 → 自动 sync 拉取未读消息

社交护城河: conversationId 与社交关系绑定在当前 SDK 实例的服务端,不可跨平台迁移,这是产品的核心壁垒。


十五、分阶段开发执行顺序

Phase交付产物核心注意点
1appStore.ts + imClient.ts + utils.ts不要在 imClient 传参数给 SDK
2App.tsx + Welcome + GenerateMnemonic + ConfirmBackup + SetNickname + VanityShop(注册后,可跳过) + RecovernewMnemonic 是同步函数!完整注册顺序:助记词生成→备份确认→设置昵称+注册→选靓号(可跳过)→主界面
3MainLayout.tsx + NetworkBanner.tsx三色横幅 + 红点角标
4MessagesTab.tsx + ContactsTab.tsxsyncFriends 已自动建 ECDH,App 不要重复做
5ChannelsTab.tsx + ChannelDetail.tsxcanPost 是本地计算,无需发请求
6ChatWindow.tsx + ImageBubblesendImage vs uploadEncryptedFile 二选一
7SettingsTab.tsx退出必须按顺序:disconnect→clearIdentity→clearAllHistory→清localStorage

十六、前端 UI 交互与工程健壮性底线约束 (UI Robustness Protocol)

由于本项目致力于“傻瓜式、高容错”的客户端体验(尤其针对移动 PWA 环境),以及高频依赖 Vibecoding(AI 直接生成组件),所有参与生成的 AI 必须严格遵守以下法则。 核心原则:不可以要求人类去小心翼翼地点击,而是要让 UI 层自动包裹防弹衣去包容任何粗暴点击。

  1. 绝对禁止“即点即溃的裸图标按钮” (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的区域)。
  2. 异步交互强制并发锁止与错误闭环 (Mandatory Async Lock & Error Closure)

    • 凡是在 onClick 等事件中调用 client. 异步方法的地带,严禁裸奔 await
    • 必须在组件体内部维护 isProcessing 或专门使用封装好的 Hook。并在按钮上绑定 disabled={isProcessing} 阻止一切网络延迟导致的狂暴连点。
    • SDK 是黑盒,绝不可在 SDK 内部吃掉错误。UI 层 try...catch 捕捉到底层 SDK 的抛出错误后,必须以对终端用户明确的格式展现出来(例如将状态机的 errorMessage 渲染在按钮附近或以 Alert 形式),杜绝任何类型的“操作无反应(Silent Failure)”。
  3. 复杂防穿透隔离机制

    • 位于 .map() 生成的嵌套卡片中用于触发非全屏切换的操作(例如在列表中同意加好友),其触发器内部的首行逻辑必须e.preventDefault()e.stopPropagation(),强制执行事件隔断。

十八、端到端音视频通话 (CallScreen.tsx)

核心架构: 呼叫信令通过 WebSocket 进行 E2EE 加密传输。媒体流通过 P2P STUN/TURN (WebRTC) 传输,默认支持 DTLS/SRTP 加密,服务端为纯盲转发层,不接触任何解密媒体。

1. 🔒 SDK 职责 (CallModule)

SDK 封装了所有底层的 WebRTC 细节,包括:

  • RTCPeerConnection 生命周期与状态转换。
  • ICE 穿透打洞 (STUN/TURN) 鉴权与获取。
  • 致命竞态防抖:SDK 内部已实现 ICE Pending 缓冲队列,专门解决 call_icesetRemoteDescription 的异步冲撞(抛出 The remote description was null)问题。此部分开发者彻底免管。
  • 媒体类型解析:从 SDP 提取是否包含视轨 (m=video),传给 UI 作出渲染决断。

2. 👤 App 职责 (AppStoreCallScreen.tsx)

开发者利用 React/Zustand 与 CallModule 配合,完成呼叫、响铃、通话挂断全生命周期的渲染。

2.1 引入与初始化方法

typescript
import { loadIdentity, deriveIdentity, fromBase64 } from '@daomessage_sdk/sdk';
import { client } from './imClient';

// 初始化通话模块(在 SDK 连接成功 + 身份可用后调用一次)
// ⚠️ 不要直接 new CallModule(),必须通过 client.initCalls() 初始化
export const initCallModule = async (alwaysRelay = false) => {
  if (client.calls) return; // 已初始化,跳过

  const ident = await loadIdentity();
  if (!ident) throw new Error('身份未加载,无法初始化通话模块');

  // 🔒 SDK:从助记词重新派生签名密钥(私钥不存 IndexedDB,每次派生)
  const fullIdent = deriveIdentity(ident.mnemonic);

  // 🔒 SDK:client.initCalls() 内部创建 CallModule,自动绑定 ICE 配置获取
  client.initCalls({
    signingPrivKey: fullIdent.signingKey.privateKey,
    signingPubKey: fullIdent.signingKey.publicKey,
    myAliasId: ident.aliasId,
    alwaysRelay,  // 付费用户可强制走 TURN 中继
  });

  // 👤 App:监听来电(client.calls 已由 initCalls 创建)
  client.calls!.onIncomingCall = (from, isVideo) => {
    const store = useAppStore.getState();
    store.beginCall(from, isVideo ? 'video' : 'audio');
    store.setCallState('ringing');
  };
  // 👤 App:监听通话结束
  client.calls!.onCallEnded = () => {
    useAppStore.getState().setCallState(null);
  };
};

// 获取通话模块实例(调用方需检查 null)
export const getCallModule = () => client.calls;

2.2 🚨 致命报错:移动端音频挂起陷阱 (Media Suspension)

在渲染纯语音通话的对方媒体流时,往往不需要在屏幕上显示画面。但绝对禁止使用 display: none(Tailwind 的 hidden! 如果 audio 标签存在于 DOM 中但被置为 display: none,iOS Safari 和移动端 Chrome 为了省电和防止恶意后台发声,会强制切断媒体播放流水线,导致语音通话毫无声音(此 BUG 排查极难发现)。

✅ 正确渲染远端音频的做法:

tsx
{/* 纯音频层(专门解决手机端隐藏 video 时截断声音流的兼容性 BUG) */}
{/* ⚠️ 必须用 absolute、w-0、opacity-0,让它留在 Render Tree 里但不影响布局 */}
<audio
  ref={remoteAudioRef}
  autoPlay playsInline
  className="absolute w-0 h-0 opacity-0 pointer-events-none"
/>

2.3 媒体绑定与 Autoplay 限制规避

React 组件挂载或重渲染时,必须从单例中取回正在通话的 Stream 并重新 srcObject 赋值:

tsx
useEffect(() => {
  if (callState === 'connected') {
    const mod = getCallModule();
    if (mod && mod.getRemoteStream()) {
      const stream = mod.getRemoteStream()!;
      // 视频通话:绑定至 <video>
      if (isVideo && remoteVideoRef.current) {
        remoteVideoRef.current.srcObject = stream;
        remoteVideoRef.current.play().catch(console.error); // 必须 catch 防止打断
      }
      // 语音通话/混合兜底:绑定至 <audio>
      if (!isVideo && remoteAudioRef.current) {
        remoteAudioRef.current.srcObject = stream;
        remoteAudioRef.current.play().catch(console.error);
      }
    }
  }
}, [callState, isVideo]);

Zero-Knowledge E2EE Protocol — Decentralized Communication