# 步骤 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` - 进入聊天页的导航入口
