Files
2026-07-20 19:01:03 +08:00

37 KiB
Raw Permalink Blame History

步骤 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 整体布局

    // 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 头部组件

    // 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 数据模型

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

    // 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 单条消息

    // 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. 消息长按操作菜单

    // 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 消息输入区与键盘联动

任务

// 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 归档权威消息
  • 消息归档必须依据 TargetModeTarget,不能用当前页面频道代替真实目标

任务

  1. 发送状态定义

    // 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 发送逻辑

    // 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 ⑤ 消息归档同步

关键原则

  • 消息归档必须依据 TargetModeTarget,不能使用当前页面频道猜测消息目标
  • 非当前频道的消息仅显示弱未读提示
  • 当前频道的消息直接追加到列表并自动滚动

任务

  1. Repository 消息归档

    // 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 消息接收处理

    // 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 回调

    // 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。

// 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 未读状态数据流

// 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 - 进入聊天页的导航入口