Messaging
Send and receive end-to-end encrypted messages. The SDK handles encryption, delivery, receipts, and local persistence.
Sending Text Messages
const messageId = await client.sendMessage(
conversationId, // e.g. "conv_abc123"
toAliasId, // e.g. "u87654321"
'Hello, World!'
);The SDK automatically:
- Encrypts the text with AES-256-GCM using the session key
- Sends via WebSocket (or queues if offline)
- Saves to local IndexedDB
- Returns the generated message ID
Sending Images
const file = inputElement.files[0]; // File from <input type="file">
const messageId = await client.sendImage(
conversationId,
toAliasId,
file,
thumbnailBase64 // Optional: low-res preview for skeleton loading
);Images are automatically compressed and encrypted before upload.
Sending Files
const messageId = await client.sendFile(conversationId, toAliasId, file);Files are uploaded without compression, encrypted end-to-end.
Sending Voice Messages
const messageId = await client.sendVoice(
conversationId,
toAliasId,
audioBlob, // Blob from MediaRecorder
durationMs // Recording duration in milliseconds
);Receiving Messages
client.on('message', (msg) => {
// msg: StoredMessage
console.log(msg.id); // Message UUID
console.log(msg.conversationId); // Conversation ID
console.log(msg.text); // Decrypted text (or JSON for media)
console.log(msg.isMe); // true if sent by current user
console.log(msg.time); // Timestamp (ms)
console.log(msg.status); // 'sending' | 'sent' | 'delivered' | 'read' | 'failed'
// ⚠️ UI 显示建议:'sent' 和 'delivered' 在 UI 层合并显示
// (用户分不清"服务端收到"和"对方设备收到"的差别)
// 仅 'read' 单独显示「已读」 — 否则用户会把 'delivered'
// 的双勾误认为已读。详见下面「状态显示规范」章节。
console.log(msg.msgType); // undefined | 'retracted' | 'image' | 'file' | 'voice'
console.log(msg.fromAliasId); // Sender's alias ID
console.log(msg.replyToId); // Original message ID (if reply)
});Message History
Messages are persisted locally in IndexedDB:
// Get all messages in a conversation
const messages = await client.getHistory(conversationId);
// Paginated loading (older messages)
const olderMessages = await client.getHistory(conversationId, {
limit: 20,
before: oldestTimestamp,
});
// Get a single message
const msg = await client.getMessageData(messageId);Read Receipts
// Mark messages as read (sends receipt to the sender)
client.markAsRead(conversationId, maxSeq, toAliasId);何时调用 markAsRead
只在「用户真的看到了消息」时调用,不要在 message 事件回调里盲目调。
正确触发条件(三选一,任一满足都算"用户在看"):
- 当前会话页面打开 + App 处于前台 (
document.visibilityState === 'visible'/ AndroidLifecycle.RESUMED) - 消息进入视口(IntersectionObserver / Android Compose
onPlaced) - 用户主动滚动到底部
错误触发(导致「我没看就显示已读」线上 bug):
- ❌ App 在后台时(用户切到别的 app)收到消息也调
- ❌ 锁屏状态下 listener 仍触发并调
- ❌ 历史消息 catch-up 时一次性给所有未读消息全调
参考实现见 template-app-pwa/src/components/chat/ChatWindow.tsx 和 template-app-android/.../ChatScreen.kt 的 handleIncoming 逻辑。
状态显示规范(UI 层必读)
StoredMessage.status 的协议层有 5 档,但 UI 层应当只显示 4 档:
| 协议状态 | UI 应显示为 | 图标建议 | 说明 |
|---|---|---|---|
sending | "发送中" | ⏱ 时钟 / · 灰 | 还没收到服务端 ack |
sent | "已发送" | ✓ 单勾灰 | 服务端 ack 了 |
delivered | 同 sent,合并显示 | ✓ 单勾灰 | 协议层概念,UI 不区分 |
read | "已读" | ✓✓ 双勾亮蓝 + "已读"文字 | 对方真看了 |
failed | "失败" | ❗ 红 | 发送失败 |
为什么 delivered 不单独显示?
- 用户分不清"服务端收到"vs"对方设备收到"的差别(都是"还没看")
- 之前 PWA / Android 给
delivered用了✓✓双勾(只是颜色比read浅), 用户视觉上误以为双勾就是已读 — 引发线上反馈「我没看就显示已读」 - 主流 IM (iMessage / Telegram) 也是把 sent + delivered 合并显示, 只有 WhatsApp 单独显示但用了完全不同的颜色饱和度(亮蓝 vs 灰)
正确做法:sent 和 delivered 用同一个图标(✓ 单勾灰), read 用 ✓✓ 双勾 + 「已读」文字 + 亮蓝色拉开视觉差距。
Typing Indicator
// Send typing status (SDK handles throttling)
client.sendTyping(conversationId, toAliasId);
// Listen for typing events
client.on('typing', (event) => {
console.log(`${event.fromAliasId} is typing in ${event.conversationId}`);
});Message Retraction
// Retract a message you sent (no time limit)
await client.retractMessage(messageId, toAliasId, conversationId);The retracted message is replaced locally with a system message and the retraction is sent to the recipient.
Reply to Messages
const messageId = await client.sendMessage(
conversationId,
toAliasId,
'Great idea!',
originalMessageId // The message being replied to
);Clear History
// Clear a single conversation's local history
await client.clearHistory(conversationId);
// Clear all local history
await client.clearAllHistory();Download Media
// Download and decrypt an image/file/voice message
const buffer = await client.media.downloadDecryptedMedia(mediaKey, conversationId);
const blob = new Blob([buffer]);
const url = URL.createObjectURL(blob);