Files
ts-mobile-go/docs/implementation/07_聊天页.md
T

1105 lines
37 KiB
Markdown
Raw Normal View History

2026-07-20 19:01:03 +08:00
# 步骤 07:聊天页
> 实现聊天页:消息列表、发送消息、消息归档。
> 对应流程:`docs/流程/04_文本消息.md`
> 依赖步骤:06(频道切换)
---
## 一、目标
- [ ] ChatScreen 页面布局(头部、消息列表、输入区、语音控制栏)
- [ ] 消息列表组件(区分自己/他人消息、自动滚动)
- [ ] 消息输入区与键盘联动(随键盘上抬、空消息禁用发送)
- [ ] SendTextMessage 流程(状态机:idle → sending → idle/failed
- [ ] OnTextMessage 消息归档(⑤ 按 TargetMode + Target 归档)
- [ ] 未读消息状态管理(弱未读红点、强未读 badge、进入频道清除)
---
## 二、任务清单
### 7.1 页面布局
**目标**:实现 ChatScreen 的三段式布局——头部、消息列表、底部(输入区 + 语音控制栏)。
**前置条件**
- 步骤 05 的 ChannelListScreen 已实现
- 步骤 06 的频道切换已完成,可通过当前频道栏跳转到聊天页
**对应 UI 设计**`docs/UI架构设计.md` 2.3 聊天页布局
**任务**
1. **ChatScreen 整体布局**
```kotlin
// ui/screens/ChatScreen.kt
@Composable
fun ChatScreen(
chatViewModel: ChatViewModel,
channelViewModel: ChannelViewModel,
voiceViewModel: VoiceViewModel,
onNavigateBack: () -> Unit,
onOpenChannelDetail: (channelId: Long) -> Unit
) {
val messages by chatViewModel.messages.collectAsState()
val currentChannel by channelViewModel.currentChannel.collectAsState()
val sendMessageState by chatViewModel.sendState.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
// 头部:返回按钮 + 频道名 + 频道详情入口
ChatHeader(
channelName = currentChannel?.name ?: "",
memberCount = currentChannel?.memberCount ?: 0,
onBack = onNavigateBack,
onChannelDetail = { currentChannel?.let { onOpenChannelDetail(it.id) } }
)
// 中部:消息列表(占满剩余空间)
Box(modifier = Modifier.weight(1f)) {
MessageList(
messages = messages,
modifier = Modifier.fillMaxSize()
)
}
// 中部-底部:消息输入区(随键盘上抬)
MessageInputBar(
sendState = sendMessageState,
onSendMessage = { text -> chatViewModel.sendMessage(text) },
modifier = Modifier.fillMaxWidth()
)
// 底部:语音控制栏(固定在底部,不受键盘影响)
VoiceControlBar(
voiceViewModel = voiceViewModel,
modifier = Modifier.fillMaxWidth()
)
}
}
```
2. **ChatHeader 头部组件**
```kotlin
// ui/components/ChatHeader.kt
@Composable
fun ChatHeader(
channelName: String,
memberCount: Int,
onBack: () -> Unit,
onChannelDetail: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 左侧:返回按钮
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回"
)
}
// 中部:频道名 + 人数
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = channelName,
style = MaterialTheme.typography.titleMedium
)
Text(
text = "${memberCount}人",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// 右侧:频道详情入口
IconButton(onClick = onChannelDetail) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "频道详情"
)
}
}
}
```
### 7.2 消息列表组件
**目标**:实现消息列表渲染,区分自己/他人消息,支持自动滚动和历史浏览。
**对应 UI 设计**`docs/UI架构设计.md` 2.3 消息显示格式
**任务**
1. **ChatMessage 数据模型**
```kotlin
// data/Models.kt
/**
* 聊天消息实体
* 权威来源:OnTextMessage(对应 notifytextmessage
*/
data class ChatMessage(
val id: String, // 唯一标识(用于列表 key)
val targetMode: Int, // 1=私聊, 2=频道, 3=服务器
val targetId: Long, // 目标 ID(频道 ID 或客户端 ID)
val senderId: Int, // 发送者客户端 ID
val senderName: String, // 发送者昵称
val content: String, // 消息内容
val timestamp: Long, // 消息时间戳
val isSelf: Boolean // 是否是自己发送的
)
```
2. **MessageList 消息列表**
```kotlin
// ui/components/MessageList.kt
@Composable
fun MessageList(
messages: List<ChatMessage>,
modifier: Modifier = Modifier
) {
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
// 是否在底部(用于判断是否自动滚动)
val isAtBottom by remember {
derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
lastVisibleItem != null && lastVisibleItem.index >= messages.size - 2
}
}
// 新消息到达时自动滚动到底部
LaunchedEffect(messages.size) {
if (isAtBottom || messages.isNotEmpty()) {
listState.animateScrollToItem(messages.size - 1)
}
}
LazyColumn(
state = listState,
modifier = modifier.padding(horizontal = 8.dp),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(
items = messages,
key = { it.id }
) { message ->
MessageItem(message = message)
}
}
// 用户查看历史消息时,新消息到达显示 "↓ 新消息" 提示条
if (!isAtBottom && messages.isNotEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
contentAlignment = Alignment.Center
) {
FilledTonalButton(
onClick = {
coroutineScope.launch {
listState.animateScrollToItem(messages.size - 1)
}
}
) {
Icon(Icons.Default.ArrowDropDown, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("新消息")
}
}
}
}
```
3. **MessageItem 单条消息**
```kotlin
// ui/components/MessageItem.kt
@Composable
fun MessageItem(message: ChatMessage) {
val timeText = remember(message.timestamp) {
SimpleDateFormat("HH:mm", Locale.getDefault())
.format(Date(message.timestamp))
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalAlignment = if (message.isSelf) Alignment.End else Alignment.Start
) {
// 发送者名称 + 时间
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (message.isSelf) Arrangement.End else Arrangement.Start,
modifier = Modifier.fillMaxWidth()
) {
if (!message.isSelf) {
Text(
text = message.senderName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(8.dp))
}
Text(
text = timeText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (message.isSelf) {
Spacer(Modifier.width(8.dp))
Text(
text = message.senderName,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
Spacer(Modifier.height(2.dp))
// 消息气泡
Surface(
shape = RoundedCornerShape(12.dp),
color = if (message.isSelf) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
modifier = Modifier.widthIn(max = 280.dp)
) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
}
}
```
4. **消息长按操作菜单**
```kotlin
// ui/components/MessageContextMenu.kt
@Composable
fun MessageContextMenu(
message: ChatMessage,
onCopy: () -> Unit,
onPokeSender: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(message.senderName) },
text = {
Column {
DropdownMenuItem(
text = { Text("📋 复制") },
onClick = {
onCopy()
onDismiss()
}
)
DropdownMenuItem(
text = { Text("🫴 Poke") },
onClick = {
onPokeSender()
onDismiss()
}
)
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text("取消") }
}
)
}
```
### 7.3 消息输入区
**目标**:实现消息输入框和发送按钮,支持键盘联动。
**对应 UI 设计**`docs/UI架构设计.md` 2.3 消息输入区与键盘联动
**任务**
```kotlin
// ui/components/MessageInputBar.kt
@Composable
fun MessageInputBar(
sendState: MessageSendState,
onSendMessage: (String) -> Unit,
modifier: Modifier = Modifier
) {
var inputText by remember { mutableStateOf("") }
// 发送成功后清空输入框
LaunchedEffect(sendState) {
if (sendState is MessageSendState.Idle && sendState.justSent) {
inputText = ""
}
}
Row(
modifier = modifier
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 输入框
OutlinedTextField(
value = inputText,
onValueChange = { inputText = it },
modifier = Modifier.weight(1f),
placeholder = {
Text(
"输入消息...",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
maxLines = 4,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Send
),
keyboardActions = KeyboardActions(
onSend = {
if (inputText.isNotBlank()) {
onSendMessage(inputText.trim())
}
}
)
)
Spacer(Modifier.width(8.dp))
// 发送按钮(输入框为空时置灰不可点击)
IconButton(
onClick = {
onSendMessage(inputText.trim())
},
enabled = inputText.isNotBlank() && sendState !is MessageSendState.Sending
) {
Icon(
imageVector = Icons.Default.Send,
contentDescription = "发送",
tint = if (inputText.isNotBlank()) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
}
```
**键盘联动要求**
- 点击输入框 → 唤起输入法 → 输入区 + 发送按钮随键盘上抬
- 语音控制栏保持固定在底部,不受键盘影响
- 使用 `WindowCompat.setDecorFitsSystemWindows(window, false)` + `imePadding()` 实现
### 7.4 发送消息流程
**目标**:实现 SendTextMessage 的完整流程,包含状态管理和错误处理。
**对应流程**`docs/流程/04_文本消息.md` 时序图
**关键原则**
- `SendTextMessage` 返回 `nil` 只表示服务器接受请求
- 服务端会向自己回推 `notifytextmessage`,用 `OnTextMessage` 归档权威消息
- 消息归档必须依据 `TargetMode` 和 `Target`,不能用当前页面频道代替真实目标
**任务**
1. **发送状态定义**
```kotlin
// data/Models.kt
/**
* 消息发送状态机
* 对应 docs/流程/04_文本消息.md 中的聊天状态
*
* 状态转换:
* Idle → Sending:用户提交发送意图
* Sending → Idle:命令成功(等待 OnTextMessage 归档)
* Sending → Failed:命令被拒绝
* Failed → Sending:用户重试
*/
sealed class MessageSendState {
/** 空闲,可以发送新消息 */
data class Idle(val justSent: Boolean = false) : MessageSendState()
/** 正在发送 SendTextMessage 等待响应 */
object Sending : MessageSendState()
/** 发送失败 */
data class Failed(val error: String) : MessageSendState()
}
```
2. **ChatViewModel 发送逻辑**
```kotlin
// viewmodel/ChatViewModel.kt
class ChatViewModel(
private val repository: Repository,
private val application: Application
) : ViewModel() {
companion object {
private const val TAG = "ChatViewModel"
}
// 当前频道的消息列表
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages
// 发送状态
private val _sendState = MutableStateFlow<MessageSendState>(MessageSendState.Idle())
val sendState: StateFlow<MessageSendState> = _sendState
// 当前查看的会话(用于筛选消息)
private var currentTargetMode: Int = 2 // 默认频道消息
private var currentTargetId: Long = 0
/**
* 切换当前查看的会话
* 进入聊天页时调用,加载对应频道的消息
*/
fun enterChat(targetMode: Int, targetId: Long) {
currentTargetMode = targetMode
currentTargetId = targetId
// 从归档中加载消息
_messages.value = repository.getMessages(targetMode, targetId)
// 清除该频道的未读标记
repository.clearUnread(targetMode, targetId)
}
/**
* 发送文本消息
* 对应 docs/流程/04_文本消息.md 时序图
*/
fun sendMessage(text: String) {
if (text.isBlank()) return
if (_sendState.value is MessageSendState.Sending) {
Log.w(TAG, "Message send already in progress")
return
}
viewModelScope.launch {
_sendState.value = MessageSendState.Sending
try {
// 通过 TSBridge 发送消息
// targetMode: 2=频道, targetId=当前频道ID
val error = TSBridge.sendTextMessage(
currentTargetMode,
currentTargetId.toString(),
text
)
if (error.isEmpty()) {
// 命令成功:服务器接受发送请求
// 服务端会回推 OnTextMessage,由 handleTextMessage 归档
Log.d(TAG, "SendTextMessage accepted by server")
_sendState.value = MessageSendState.Idle(justSent = true)
} else {
// 命令被拒绝
Log.w(TAG, "SendTextMessage rejected: $error")
_sendState.value = MessageSendState.Failed(mapSendError(error))
}
} catch (e: Exception) {
Log.e(TAG, "SendTextMessage failed", e)
_sendState.value = MessageSendState.Failed("发送失败:${e.message}")
}
}
}
/**
* 重试发送(失败后)
*/
fun retrySend(text: String) {
_sendState.value = MessageSendState.Idle()
sendMessage(text)
}
/**
* 映射发送错误为用户友好提示
*/
private fun mapSendError(error: String): String {
return when {
error.contains("permission", ignoreCase = true) -> "权限不足,无法发送消息"
error.contains("flood", ignoreCase = true) -> "发送过于频繁,请稍后再试"
error.contains("empty", ignoreCase = true) -> "消息内容不能为空"
else -> "发送失败:$error"
}
}
}
```
### 7.5 消息归档逻辑
**目标**:实现 OnTextMessage 事件处理,按 TargetMode + Target 归档消息。
**对应流程**`docs/流程/08_状态同步.md` ⑤ 消息归档同步
**关键原则**
- 消息归档必须依据 `TargetMode` 和 `Target`,不能使用当前页面频道猜测消息目标
- 非当前频道的消息仅显示弱未读提示
- 当前频道的消息直接追加到列表并自动滚动
**任务**
1. **Repository 消息归档**
```kotlin
// data/Repository.kt
class Repository {
// ... 已有实现 ...
// 消息归档:按 (TargetMode, Target) 分组存储
// key = "${targetMode}_${targetId}"
private val messageArchives = ConcurrentHashMap<String, MutableList<ChatMessage>>()
// 未读状态:key = "${targetMode}_${targetId}", value = 未读数
private val _unreadCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
val unreadCounts: StateFlow<Map<String, Int>> = _unreadCounts
// 当前查看的会话(用于判断是否需要未读提示)
private var activeChatKey: String? = null
/**
* 归档消息(由 OnTextMessage 调用)
* 对应 docs/流程/08_状态同步.md ⑤ 消息归档同步
*
* 必须读取 TargetMode 和 Target,不能使用当前页面猜测目标
*/
fun archiveMessage(message: ChatMessage) {
val key = "${message.targetMode}_${message.targetId}"
// 归档到对应会话
val archive = messageArchives.getOrPut(key) { mutableListOf() }
synchronized(archive) {
archive.add(message)
// 限制每个会话最多保留 500 条消息
if (archive.size > 500) {
archive.removeAt(0)
}
}
// 如果不是当前查看的会话,增加未读计数
if (key != activeChatKey) {
val currentCounts = _unreadCounts.value.toMutableMap()
currentCounts[key] = (currentCounts[key] ?: 0) + 1
_unreadCounts.value = currentCounts
}
// 如果是当前查看的会话,更新消息列表
if (key == activeChatKey) {
// 触发 UI 更新
notifyMessagesChanged(message.targetMode, message.targetId)
}
}
/**
* 获取指定会话的消息列表
*/
fun getMessages(targetMode: Int, targetId: Long): List<ChatMessage> {
val key = "${targetMode}_${targetId}"
val archive = messageArchives[key] ?: emptyList()
return synchronized(archive) { archive.toList() }
}
/**
* 设置当前活跃会话(进入聊天页时调用)
*/
fun setActiveChat(targetMode: Int?, targetId: Long?) {
activeChatKey = if (targetMode != null && targetId != null) {
"${targetMode}_${targetId}"
} else {
null
}
}
/**
* 清除指定会话的未读标记
*/
fun clearUnread(targetMode: Int, targetId: Long) {
val key = "${targetMode}_${targetId}"
val currentCounts = _unreadCounts.value.toMutableMap()
currentCounts.remove(key)
_unreadCounts.value = currentCounts
}
}
```
2. **ChatViewModel 消息接收处理**
```kotlin
// viewmodel/ChatViewModel.kt
/**
* 处理收到的文本消息
* 由 ServerViewModel 的 OnTextMessage 回调触发
*
* 对应 docs/流程/04_文本消息.md
* "OnTextMessage 归档:按 TargetMode 与 Target 归档
* 不能使用当前页面频道代替真实目标"
*/
fun handleTextMessage(
targetMode: Int,
targetId: Long,
senderId: Int,
senderName: String,
content: String
) {
val selfId = repository.selfClientId.value
val message = ChatMessage(
id = "${System.currentTimeMillis()}_${senderId}",
targetMode = targetMode,
targetId = targetId,
senderId = senderId,
senderName = senderName,
content = content,
timestamp = System.currentTimeMillis(),
isSelf = senderId == selfId
)
// 归档消息(Repository 内部处理未读计数)
repository.archiveMessage(message)
Log.d(TAG, "Message archived: mode=$targetMode, target=$targetId, from=$senderName")
}
/**
* 离开聊天页时清理
*/
fun leaveChat() {
repository.setActiveChat(null, null)
}
```
3. **ServerViewModel 注册 OnTextMessage 回调**
```kotlin
// ServerViewModel.kt - 在 registerEventHandlers 中添加
fun registerEventHandlers() {
TSBridge.setCallbacks(object : TSBridge.Callbacks {
// ... 已有回调 ...
override fun onTextMessage(
targetMode: Int,
targetId: String,
invokerId: Int,
invokerName: String,
message: String
) {
val targetIdLong = targetId.toLongOrNull() ?: return
chatViewModel.handleTextMessage(
targetMode = targetMode,
targetId = targetIdLong,
senderId = invokerId,
senderName = invokerName,
content = message
)
}
// ... 其他回调 ...
})
}
```
### 7.6 ChatViewModel 完整实现
**目标**:整合以上各部分,实现完整的 ChatViewModel。
```kotlin
// viewmodel/ChatViewModel.kt
package com.tsmobile.app.viewmodel
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.tsmobile.app.TSBridge
import com.tsmobile.app.data.ChatMessage
import com.tsmobile.app.data.MessageSendState
import com.tsmobile.app.data.Repository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class ChatViewModel(
private val repository: Repository,
private val application: Application
) : AndroidViewModel(application) {
companion object {
private const val TAG = "ChatViewModel"
private const val MAX_MESSAGES_PER_CHAT = 500
}
// 当前会话的消息列表
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages
// 发送状态
private val _sendState = MutableStateFlow<MessageSendState>(MessageSendState.Idle())
val sendState: StateFlow<MessageSendState> = _sendState
// 当前查看的会话参数
private var currentTargetMode: Int = 2
private var currentTargetId: Long = 0
/**
* 进入聊天页
* 加载消息并清除未读标记
*/
fun enterChat(targetMode: Int, targetId: Long) {
currentTargetMode = targetMode
currentTargetId = targetId
// 设置活跃会话(影响未读计数)
repository.setActiveChat(targetMode, targetId)
// 加载归档消息
_messages.value = repository.getMessages(targetMode, targetId)
// 清除该会话的未读标记
repository.clearUnread(targetMode, targetId)
Log.d(TAG, "Entered chat: mode=$targetMode, target=$targetId, messages=${_messages.value.size}")
}
/**
* 离开聊天页
*/
fun leaveChat() {
repository.setActiveChat(null, null)
_sendState.value = MessageSendState.Idle()
Log.d(TAG, "Left chat")
}
/**
* 发送文本消息
* 对应 docs/流程/04_文本消息.md 时序图
*/
fun sendMessage(text: String) {
if (text.isBlank()) return
if (_sendState.value is MessageSendState.Sending) {
Log.w(TAG, "Send already in progress")
return
}
viewModelScope.launch {
_sendState.value = MessageSendState.Sending
try {
val error = TSBridge.sendTextMessage(
currentTargetMode,
currentTargetId.toString(),
text
)
if (error.isEmpty()) {
Log.d(TAG, "SendTextMessage accepted")
_sendState.value = MessageSendState.Idle(justSent = true)
} else {
Log.w(TAG, "SendTextMessage rejected: $error")
_sendState.value = MessageSendState.Failed(mapSendError(error))
}
} catch (e: Exception) {
Log.e(TAG, "SendTextMessage exception", e)
_sendState.value = MessageSendState.Failed("发送失败:${e.message}")
}
}
}
/**
* 处理收到的文本消息(由 ServerViewModel 调用)
* 对应 docs/流程/08_状态同步.md ⑤ 消息归档同步
*/
fun handleTextMessage(
targetMode: Int,
targetId: Long,
senderId: Int,
senderName: String,
content: String
) {
val selfId = repository.selfClientId.value
val message = ChatMessage(
id = "${System.currentTimeMillis()}_${senderId}",
targetMode = targetMode,
targetId = targetId,
senderId = senderId,
senderName = senderName,
content = content,
timestamp = System.currentTimeMillis(),
isSelf = senderId == selfId
)
repository.archiveMessage(message)
// 如果是当前查看的会话,更新消息列表
if (targetMode == currentTargetMode && targetId == currentTargetId) {
_messages.value = repository.getMessages(targetMode, targetId)
}
}
/**
* 重试发送
*/
fun retrySend(text: String) {
_sendState.value = MessageSendState.Idle()
sendMessage(text)
}
/**
* 清除发送状态(用于 UI 重置)
*/
fun clearSendState() {
_sendState.value = MessageSendState.Idle()
}
private fun mapSendError(error: String): String {
return when {
error.contains("permission", ignoreCase = true) -> "权限不足"
error.contains("flood", ignoreCase = true) -> "发送过于频繁"
error.contains("empty", ignoreCase = true) -> "消息不能为空"
else -> "发送失败:$error"
}
}
}
```
---
## 三、状态与数据流
### 3.1 消息发送状态机
```
┌─────────────────────────────────┐
│ │
▼ │
┌─────────┐ │
│ Idle │◄──────────────────────────┤
└────┬────┘ │
│ 用户点击发送 │
▼ │
┌──────────┐ │
│ Sending │ │
└────┬─────┘ │
│ │
┌─────────┴──────────┐ │
│ │ │
▼ ▼ │
error="" error! │
│ │ │
▼ ▼ │
Idle(justSent) Failed │
│ │ │
│ 用户重试 │
│ │ │
└────────────────────┴─────────────────────┘
```
### 3.2 消息归档数据流
```
服务端 SDK Repository ChatViewModel UI
│ │ │ │ │
│ notifytextmessage │ │ │ │
├────────────────────→│ │ │ │
│ │ OnTextMessage │ │ │
│ ├───────────────────────→│ │ │
│ │ │ │ │
│ │ │ 读取 TargetMode │ │
│ │ │ + Target │ │
│ │ │ │ │
│ │ │ 归档到对应会话 │ │
│ │ │ │ │
│ │ │ 当前会话? │ │
│ │ ├─ 是 ─────────────────→│ 更新消息列表 │
│ │ │ ├─────────────────→│
│ │ │ │ │
│ │ ├─ 否 ─→ 增加未读计数 │ │
│ │ │ │ │
```
### 3.3 命令响应与事件事实的区分
**关键原则**(对应 `docs/流程/04_文本消息.md`):
| 概念 | 含义 | 处理方式 |
|------|------|----------|
| SendTextMessage 返回 error | 命令被服务器拒绝 | 立即显示错误,状态 → Failed |
| SendTextMessage 返回 nil | 命令被服务器接受 | 状态 → Idle(justSent=true) |
| OnTextMessage(self) | 服务端回推自己的消息 | 归档到对应会话,更新列表 |
**为什么发送成功后要等 OnTextMessage 归档?**
- `SendTextMessage` 返回 nil 只表示服务器接受了请求
- 服务端会向自己回推 `notifytextmessage`
- 这条回推消息包含服务器处理后的完整信息(如时间戳)
- 用 `OnTextMessage` 归档确保消息列表的一致性
---
## 四、未读消息管理
### 4.1 未读状态定义
| 状态 | 显示 | 触发条件 | 清除条件 |
|------|------|----------|----------|
| 无未读 | 正常显示 | — | — |
| 弱未读 | 频道名右侧红点 ● | 非当前频道收到普通消息 | 用户进入该频道 |
| 强未读 | 频道名右侧数字 badge | 非当前频道收到 @提及 | 用户进入该频道 |
| 当前频道 | 不显示未读 | 用户正在查看该频道 | — |
### 4.2 未读状态数据流
```kotlin
// ChannelListScreen.kt - 在频道树中显示未读状态
@Composable
fun ChannelRow(
channel: ChannelInfo,
unreadCount: Int,
isCurrentChannel: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// ... 频道名 ...
Spacer(Modifier.weight(1f))
// 未读指示(当前频道不显示)
if (!isCurrentChannel && unreadCount > 0) {
if (unreadCount > 99) {
// 强未读:显示数字
Badge { Text("99+") }
} else if (unreadCount > 1) {
// 强未读:显示数字
Badge { Text("$unreadCount") }
} else {
// 弱未读:显示红点
Box(
modifier = Modifier
.size(8.dp)
.background(
color = MaterialTheme.colorScheme.error,
shape = CircleShape
)
)
}
}
}
}
```
---
## 五、验收标准
### 功能验收
- [ ] **页面布局**
- 头部显示频道名、人数、返回按钮、频道详情入口
- 消息列表占满中间区域,支持滚动
- 输入区在底部,随键盘上抬
- 语音控制栏固定在最底部
- [ ] **消息显示**
- 自己的消息靠右对齐,使用不同背景色
- 他人的消息靠左对齐,显示发送者名称
- 每条消息显示发送时间(HH:mm 格式)
- 新消息到达时自动滚动到底部
- 查看历史消息时显示 "↓ 新消息" 提示条
- [ ] **发送消息**
- 输入框为空时发送按钮置灰
- 点击发送或键盘回车触发发送
- 发送中显示加载状态
- 发送成功清空输入框,保持键盘打开
- 发送失败显示错误提示,可重试
- [ ] **消息归档**
- 收到 OnTextMessage 按 TargetMode + Target 归档
- 当前频道消息直接追加到列表
- 非当前频道消息增加未读计数
- 进入频道时清除未读标记
- [ ] **未读指示**
- 频道列表页显示弱未读红点(1条新消息)
- 频道列表页显示强未读数字(多条新消息)
- 当前所在频道不显示未读标记
### 错误处理验收
| 错误场景 | 预期行为 |
|----------|----------|
| 权限不足 | 提示"权限不足,无法发送消息" |
| 发送过于频繁 | 提示"发送过于频繁,请稍后再试" |
| 网络断开 | 提示"发送失败",可重试 |
| 消息内容为空 | 发送按钮禁用 |
### 性能验收
- [ ] 消息列表滚动流畅(500 条消息)
- [ ] 新消息到达自动滚动无卡顿
- [ ] 输入框输入响应 < 50ms
### 测试用例
| 场景 | 操作 | 预期结果 |
|------|------|----------|
| 发送频道消息 | 输入文本 → 点击发送 | 消息出现在列表右侧 |
| 接收他人消息 | 他人发送消息 | 消息出现在列表左侧,显示发送者名 |
| 自动滚动 | 收到新消息 | 列表自动滚动到底部 |
| 查看历史 | 向上滚动查看历史 | 显示"↓ 新消息"提示条 |
| 未读指示 | 切换到其他频道收到消息 | 频道名显示红点 |
| 清除未读 | 进入有未读的频道 | 红点消失 |
| 发送失败 | 断网后发送 | 显示错误提示,可重试 |
| 空消息 | 输入框为空 | 发送按钮置灰 |
| 长按消息 | 长按消息气泡 | 弹出操作菜单(复制/Poke) |
---
## 六、参考文档
- `docs/流程/04_文本消息.md` - 时序图、状态机、事件依赖
- `docs/流程/08_状态同步.md` - ⑤ 消息归档同步
- `docs/UI架构设计.md` - 2.3 聊天页布局、5.5 未读消息指示
- `docs/sdk文档-go.md` - SendTextMessage API、OnTextMessage 事件、TextMessage 结构
- `docs/implementation/02_Bridge层实现.md` - TSBridge.sendTextMessage、onTextMessage 回调
- `docs/implementation/05_频道列表页.md` - ChannelListScreen 未读指示集成
- `docs/implementation/06_频道切换.md` - 进入聊天页的导航入口