From 59611de3c1fe598b1f886ef5ac43b1c72e8e644d Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Mon, 27 Apr 2026 23:41:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E6=B7=BB=E5=8A=A0=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E7=95=8C=E9=9D=A2=E5=92=8C=E4=BC=9A=E8=AF=9D=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了本地IM缓存功能,支持会话、消息历史和草稿的存储 - 开发了聊天界面UI组件,包含消息列表、输入框和搜索功能 - 创建了聊天相关的ViewModel,处理消息收发和状态管理 - 构建了会话列表界面,支持置顶、免打扰和删除操作 - 集成了群组功能,实现群聊管理和群设置界面 - 添加了实时消息推送和会话状态同步机制 --- README.md | 6 ++ .../sdk/sample/data/local/LocalImCache.kt | 43 +++++++++++++- .../com/xuqm/sdk/sample/ui/chat/ChatScreen.kt | 38 +++++++++---- .../xuqm/sdk/sample/ui/chat/ChatViewModel.kt | 16 ++++++ .../ui/conversation/ConversationScreen.kt | 15 +++++ .../ui/conversation/ConversationViewModel.kt | 19 ++++++- .../xuqm/sdk/sample/ui/group/GroupScreen.kt | 57 ++++++++++++++++++- sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt | 11 ++++ .../main/java/com/xuqm/sdk/im/api/ImApi.kt | 15 +++++ 9 files changed, 207 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index baca223..8043a89 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,12 @@ val service = RetrofitFactory.create(MyApiService::class.java) - 联系人列表先读本地缓存,再刷新网络 - 聊天历史分页加载 - 当前会话本地搜索 +- 输入草稿自动保存 +- 群设置支持编辑群名和群公告 +- 会话支持本地删除 +- 显示总未读数 +- 消息状态直接展示 +- 会话置顶/免打扰/已读/草稿/删除同步服务端 - IM 连接状态提示 - SDK 登录态恢复后自动重连 diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt index 324433c..462823e 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/data/local/LocalImCache.kt @@ -13,10 +13,12 @@ class LocalImCache(context: Context) { fun loadConversations(): List = readConversationList(KEY_CONVERSATIONS) fun saveConversations(conversations: List) { - prefs.edit().putString(KEY_CONVERSATIONS, serializeConversationList(conversations)).apply() + val visible = conversations.filterNot { isHiddenConversation(it.targetId, it.chatType) } + prefs.edit().putString(KEY_CONVERSATIONS, serializeConversationList(visible)).apply() } fun upsertConversation(conversation: ConversationData) { + clearHiddenConversation(conversation.targetId, conversation.chatType) val existing = loadConversations().firstOrNull { it.targetId == conversation.targetId && it.chatType == conversation.chatType } @@ -64,6 +66,29 @@ class LocalImCache(context: Context) { saveHistory(targetId, chatType, merged) } + fun saveDraft(targetId: String, chatType: String, draft: String) { + prefs.edit().putString(draftKey(targetId, chatType), draft).apply() + } + + fun loadDraft(targetId: String, chatType: String): String = + prefs.getString(draftKey(targetId, chatType), "") ?: "" + + fun clearDraft(targetId: String, chatType: String) { + prefs.edit().remove(draftKey(targetId, chatType)).apply() + } + + fun deleteConversation(targetId: String, chatType: String) { + val remaining = loadConversations().filterNot { + it.targetId == targetId && it.chatType == chatType + } + prefs.edit() + .putString(KEY_CONVERSATIONS, serializeConversationList(remaining)) + .remove(historyKey(targetId, chatType)) + .remove(draftKey(targetId, chatType)) + .putStringSet(KEY_HIDDEN_CONVERSATIONS, loadHiddenConversations() + conversationKey(targetId, chatType)) + .apply() + } + private fun readConversationList(key: String): List { val raw = prefs.getString(key, null) ?: return emptyList() return runCatching { @@ -167,9 +192,25 @@ class LocalImCache(context: Context) { } private fun historyKey(targetId: String, chatType: String) = "history_${chatType}_$targetId" + private fun draftKey(targetId: String, chatType: String) = "draft_${chatType}_$targetId" + private fun conversationKey(targetId: String, chatType: String) = "${chatType}_$targetId" + + private fun loadHiddenConversations(): MutableSet = + prefs.getStringSet(KEY_HIDDEN_CONVERSATIONS, emptySet())?.toMutableSet() ?: mutableSetOf() + + private fun isHiddenConversation(targetId: String, chatType: String): Boolean = + loadHiddenConversations().contains(conversationKey(targetId, chatType)) + + private fun clearHiddenConversation(targetId: String, chatType: String) { + val hidden = loadHiddenConversations() + if (hidden.remove(conversationKey(targetId, chatType))) { + prefs.edit().putStringSet(KEY_HIDDEN_CONVERSATIONS, hidden).apply() + } + } private companion object { const val PREFS_NAME = "xuqm_demo_im_cache" const val KEY_CONVERSATIONS = "conversations" + const val KEY_HIDDEN_CONVERSATIONS = "hidden_conversations" } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt index 8d63363..1de4a32 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatScreen.kt @@ -61,12 +61,12 @@ fun ChatScreen( val messages by viewModel.messages.collectAsStateWithLifecycle() val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() val searchResults by viewModel.searchResults.collectAsStateWithLifecycle() + val draftText by viewModel.draftText.collectAsStateWithLifecycle() val scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle() val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle() val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle() val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle() val listState = rememberLazyListState() - var input by remember { mutableStateOf("") } var showSearchBar by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.init(targetId, chatType) } @@ -130,16 +130,16 @@ fun ChatScreen( verticalAlignment = Alignment.CenterVertically, ) { OutlinedTextField( - value = input, - onValueChange = { input = it }, + value = draftText, + onValueChange = viewModel::updateDraft, modifier = Modifier.weight(1f), placeholder = { Text("输入消息…") }, maxLines = 4, shape = RoundedCornerShape(24.dp), ) IconButton( - onClick = { viewModel.sendText(input); input = "" }, - enabled = input.isNotBlank(), + onClick = { viewModel.sendText(draftText) }, + enabled = draftText.isNotBlank(), ) { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) } @@ -246,11 +246,19 @@ private fun MessageBubble(message: ImMessage, isOwn: Boolean) { .widthIn(max = 280.dp) .padding(horizontal = 4.dp), ) { - Text( - text = parseContent(message), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - style = MaterialTheme.typography.bodyMedium, - ) + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + Text( + text = parseContent(message), + style = MaterialTheme.typography.bodyMedium, + ) + if (isOwn) { + Text( + text = statusLabel(message.status), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } } if (isOwn) { @@ -259,6 +267,16 @@ private fun MessageBubble(message: ImMessage, isOwn: Boolean) { } } +private fun statusLabel(status: String): String = when (status.uppercase()) { + "SENDING" -> "发送中" + "SENT" -> "已发送" + "DELIVERED" -> "已送达" + "READ" -> "已读" + "FAILED" -> "发送失败" + "REVOKED" -> "已撤回" + else -> status +} + @Composable private fun AvatarPlaceholder(userId: String) { InitialAvatar(text = userId, modifier = Modifier.size(32.dp)) diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt index f88afe5..ffb2394 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/chat/ChatViewModel.kt @@ -32,6 +32,9 @@ class ChatViewModel : ViewModel() { private val _searchResults = MutableStateFlow>(emptyList()) val searchResults: StateFlow> = _searchResults + private val _draftText = MutableStateFlow("") + val draftText: StateFlow = _draftText + val currentUserId: String get() = ImSDK.currentUserId private lateinit var targetId: String @@ -98,6 +101,7 @@ class ChatViewModel : ViewModel() { _isLoadingMore.value = false _searchQuery.value = "" _searchResults.value = emptyList() + _draftText.value = cache.loadDraft(targetId, chatType) ImSDK.addListener(listener) loadInitialHistory() viewModelScope.launch { @@ -173,6 +177,8 @@ class ChatViewModel : ViewModel() { fun sendText(content: String) { if (content.isBlank()) return ImSDK.sendMessage(targetId, chatType, "TEXT", content) + _draftText.value = "" + cache.clearDraft(targetId, chatType) cache.upsertConversation( ConversationData( targetId = targetId, @@ -196,6 +202,16 @@ class ChatViewModel : ViewModel() { } } + fun updateDraft(text: String) { + _draftText.value = text + if (initialized) { + cache.saveDraft(targetId, chatType, text) + viewModelScope.launch { + runCatching { ImSDK.setDraft(targetId, chatType, text) } + } + } + } + fun clearSearch() { _searchQuery.value = "" _searchResults.value = emptyList() diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt index 6a576be..fc42033 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationScreen.kt @@ -45,6 +45,7 @@ fun ConversationScreen( viewModel: ConversationViewModel = viewModel(), ) { val conversations by viewModel.conversations.collectAsStateWithLifecycle() + val totalUnreadCount by viewModel.totalUnreadCount.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() var query by remember { mutableStateOf("") } val filtered = remember(conversations, query) { @@ -58,6 +59,12 @@ fun ConversationScreen( } Column(modifier = Modifier.fillMaxSize()) { + Text( + "总未读 $totalUnreadCount", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline, + ) SearchBarField( value = query, onValueChange = { query = it }, @@ -88,6 +95,9 @@ fun ConversationScreen( onMuteToggle = { scope.launch { viewModel.setMuted(conv.targetId, conv.chatType, !conv.isMuted) } }, + onDelete = { + viewModel.deleteConversation(conv.targetId, conv.chatType) + }, ) HorizontalDivider(modifier = Modifier.padding(start = 72.dp)) } @@ -103,6 +113,7 @@ private fun ConversationItem( onClick: () -> Unit, onPinToggle: () -> Unit, onMuteToggle: () -> Unit, + onDelete: () -> Unit, ) { var showMenu by remember { mutableStateOf(false) } @@ -160,6 +171,10 @@ private fun ConversationItem( text = { Text(if (conversation.isMuted) "取消免打扰" else "免打扰") }, onClick = { showMenu = false; onMuteToggle() }, ) + DropdownMenuItem( + text = { Text("删除会话") }, + onClick = { showMenu = false; onDelete() }, + ) } } } diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt index 1fb4e3f..0fe27be 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/conversation/ConversationViewModel.kt @@ -18,6 +18,9 @@ class ConversationViewModel : ViewModel() { private val _conversations = MutableStateFlow>(emptyList()) val conversations: StateFlow> = _conversations + private val _totalUnreadCount = MutableStateFlow(0) + val totalUnreadCount: StateFlow = _totalUnreadCount + private val listener = object : ImEventListener { override fun onMessage(message: ImMessage) { refresh() } override fun onGroupMessage(message: ImMessage) { refresh() } @@ -26,6 +29,7 @@ class ConversationViewModel : ViewModel() { init { ImSDK.addListener(listener) _conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime } + _totalUnreadCount.value = _conversations.value.sumOf { it.unreadCount } refresh() viewModelScope.launch { ImSDK.connectionState.collect { state -> @@ -43,14 +47,27 @@ class ConversationViewModel : ViewModel() { val sorted = list.sortedByDescending { it.lastMsgTime } cache.saveConversations(sorted) _conversations.value = sorted + _totalUnreadCount.value = sorted.sumOf { it.unreadCount } } .onFailure { val cached = cache.loadConversations().sortedByDescending { it.lastMsgTime } - if (cached.isNotEmpty()) _conversations.value = cached + if (cached.isNotEmpty()) { + _conversations.value = cached + _totalUnreadCount.value = cached.sumOf { it.unreadCount } + } } } } + fun deleteConversation(targetId: String, chatType: String) { + viewModelScope.launch { + runCatching { ImSDK.deleteConversation(targetId, chatType) } + cache.deleteConversation(targetId, chatType) + _conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime } + _totalUnreadCount.value = _conversations.value.sumOf { it.unreadCount } + } + } + suspend fun setPinned(targetId: String, chatType: String, pinned: Boolean) { runCatching { ImSDK.setConversationPinned(targetId, chatType, pinned) } refresh() diff --git a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt index ff8c00d..c693eb7 100644 --- a/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt +++ b/sample-app/src/main/java/com/xuqm/sdk/sample/ui/group/GroupScreen.kt @@ -155,8 +155,15 @@ fun GroupSettingsScreen( ) { val group by viewModel.currentGroup.collectAsStateWithLifecycle() val connectionState by ImSDK.connectionState.collectAsStateWithLifecycle() + var showEditDialog by remember { mutableStateOf(false) } + var editName by remember { mutableStateOf("") } + var editAnnouncement by remember { mutableStateOf("") } LaunchedEffect(groupId) { viewModel.loadGroupInfo(groupId) } + LaunchedEffect(group) { + editName = group?.name.orEmpty() + editAnnouncement = group?.announcement.orEmpty() + } Scaffold( topBar = { @@ -177,13 +184,20 @@ fun GroupSettingsScreen( modifier = Modifier .fillMaxSize() .padding(padding) - .padding(16.dp), + .padding(16.dp), ) { group?.let { g -> + val isOwner = g.creatorId == ImSDK.currentUserId Text("群 ID: ${g.id}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline) Spacer(Modifier.height(8.dp)) Text("群公告: ${g.announcement ?: "暂无"}", style = MaterialTheme.typography.bodyMedium) + if (isOwner) { + Spacer(Modifier.height(12.dp)) + TextButton(onClick = { showEditDialog = true }) { + Text("编辑群资料") + } + } Spacer(Modifier.height(16.dp)) Text("成员", style = MaterialTheme.typography.titleSmall) val memberIds = g.memberIds.split(",").filter { it.isNotBlank() } @@ -209,4 +223,45 @@ fun GroupSettingsScreen( } } } + + if (showEditDialog && group != null) { + AlertDialog( + onDismissRequest = { showEditDialog = false }, + title = { Text("编辑群资料") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = editName, + onValueChange = { editName = it }, + label = { Text("群名称") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = editAnnouncement, + onValueChange = { editAnnouncement = it }, + label = { Text("群公告") }, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button( + onClick = { + showEditDialog = false + viewModel.updateGroup( + groupId = groupId, + name = editName.trim().ifBlank { null }, + announcement = editAnnouncement.trim().ifBlank { null }, + ) + }, + enabled = editName.isNotBlank(), + ) { + Text("保存") + } + }, + dismissButton = { + TextButton(onClick = { showEditDialog = false }) { Text("取消") } + }, + ) + } } diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt index e064dfa..b77728b 100644 --- a/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/ImSDK.kt @@ -116,6 +116,11 @@ object ImSDK { suspend fun listConversations(): List = withContext(Dispatchers.IO) { api.listConversations(XuqmSDK.appId).data ?: emptyList() } + suspend fun getTotalUnreadCount(): Int = + withContext(Dispatchers.IO) { + runCatching { listConversations().sumOf { it.unreadCount } }.getOrDefault(0) + } + suspend fun setConversationPinned(targetId: String, chatType: String, pinned: Boolean) = withContext(Dispatchers.IO) { api.setConversationPinned(targetId, chatType, SetPinnedRequest(pinned)) @@ -129,6 +134,12 @@ object ImSDK { suspend fun markRead(targetId: String, chatType: String = "SINGLE") = withContext(Dispatchers.IO) { api.markRead(targetId, XuqmSDK.appId, chatType) } + suspend fun setDraft(targetId: String, chatType: String, draft: String) = + withContext(Dispatchers.IO) { api.setDraft(targetId, XuqmSDK.appId, chatType, draft) } + + suspend fun deleteConversation(targetId: String, chatType: String) = + withContext(Dispatchers.IO) { api.deleteConversation(targetId, XuqmSDK.appId, chatType) } + fun addListener(listener: ImEventListener) = client?.addListener(listener) fun removeListener(listener: ImEventListener) = client?.removeListener(listener) diff --git a/sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt b/sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt index 098e547..72a79a7 100644 --- a/sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt +++ b/sdk-im/src/main/java/com/xuqm/sdk/im/api/ImApi.kt @@ -129,4 +129,19 @@ interface ImApi { @Query("appId") appId: String, @Query("chatType") chatType: String, ): ApiResponse + + @PUT("api/im/conversations/{targetId}/draft") + suspend fun setDraft( + @Path("targetId") targetId: String, + @Query("appId") appId: String, + @Query("chatType") chatType: String, + @Query("draft") draft: String, + ): ApiResponse + + @DELETE("api/im/conversations/{targetId}") + suspend fun deleteConversation( + @Path("targetId") targetId: String, + @Query("appId") appId: String, + @Query("chatType") chatType: String, + ): ApiResponse }