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 bea4f2b..d6d45d1 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 @@ -93,6 +93,8 @@ fun ChatScreen( val mentionableUsers by viewModel.mentionableUsers.collectAsStateWithLifecycle() val replyTargetMessage by viewModel.replyTargetMessage.collectAsStateWithLifecycle() val editingMessage by viewModel.editingMessage.collectAsStateWithLifecycle() + val focusMessageId by viewModel.focusMessageId.collectAsStateWithLifecycle() + val focusMessageSignal by viewModel.focusMessageSignal.collectAsStateWithLifecycle() val scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle() val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle() val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle() @@ -195,6 +197,14 @@ fun ChatScreen( listState.animateScrollToItem(0) } } + LaunchedEffect(focusMessageSignal, messages.size) { + val messageId = focusMessageId ?: return@LaunchedEffect + val index = messages.indexOfFirst { it.id == messageId } + if (index >= 0) { + listState.animateScrollToItem(index) + viewModel.clearFocusedMessage() + } + } LaunchedEffect(listState, hasMoreHistory, isLoadingMore, messages.size, searchQuery) { if (searchQuery.isNotBlank()) return@LaunchedEffect snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 } @@ -428,8 +438,8 @@ fun ChatScreen( if (showSearchBar || searchQuery.isNotBlank()) { SearchBarField( value = searchQuery, - onValueChange = viewModel::searchCachedMessages, - placeholder = "搜索当前会话本地消息", + onValueChange = viewModel::searchMessages, + placeholder = "搜索当前会话消息", modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), ) } @@ -445,6 +455,7 @@ fun ChatScreen( message = msg, isOwn = msg.fromId == viewModel.currentUserId, currentUserId = viewModel.currentUserId, + onClick = { viewModel.openSearchResult(msg.id) }, onReply = viewModel::startReply, onEdit = viewModel::startEdit, ) @@ -475,6 +486,7 @@ fun ChatScreen( message = msg, isOwn = msg.fromId == viewModel.currentUserId, currentUserId = viewModel.currentUserId, + onClick = {}, onReply = viewModel::startReply, onEdit = viewModel::startEdit, ) @@ -533,6 +545,7 @@ private fun MessageBubble( message: ImMessage, isOwn: Boolean, currentUserId: String, + onClick: () -> Unit, onReply: (ImMessage) -> Unit, onEdit: (ImMessage) -> Unit, ) { @@ -566,7 +579,7 @@ private fun MessageBubble( .widthIn(max = 280.dp) .padding(horizontal = 4.dp) .combinedClickable( - onClick = {}, + onClick = onClick, onLongClick = { if (isOwn && message.msgType.uppercase() == "TEXT") { onEdit(message) 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 47c3537..119ff52 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 @@ -17,6 +17,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch class ChatViewModel : ViewModel() { @@ -40,6 +42,12 @@ class ChatViewModel : ViewModel() { private val _searchResults = MutableStateFlow>(emptyList()) val searchResults: StateFlow> = _searchResults + private val _focusMessageId = MutableStateFlow(null) + val focusMessageId: StateFlow = _focusMessageId + + private val _focusMessageSignal = MutableStateFlow(0L) + val focusMessageSignal: StateFlow = _focusMessageSignal + private val _draftText = MutableStateFlow("") val draftText: StateFlow = _draftText @@ -65,6 +73,7 @@ class ChatViewModel : ViewModel() { private var nextHistoryPage = 0 private var initialized = false private val pendingDeliveryTimeouts = mutableMapOf() + private var searchJob: Job? = null private val listener = object : ImEventListener { override fun onMessage(message: ImMessage) { @@ -229,12 +238,57 @@ class ChatViewModel : ViewModel() { } } - fun searchCachedMessages(query: String) { + fun searchMessages(query: String) { _searchQuery.value = query - _searchResults.value = if (query.isBlank()) { - emptyList() - } else { - cache.searchHistory(targetId, chatType, query) + searchJob?.cancel() + if (query.isBlank() || !initialized) { + _searchResults.value = emptyList() + return + } + searchJob = viewModelScope.launch { + delay(250) + val normalized = query.trim() + val remote = runCatching { + if (chatType == "GROUP") { + ImSDK.fetchGroupHistoryWithFilters( + groupId = targetId, + page = 0, + size = SEARCH_PAGE_SIZE, + keyword = normalized, + ) + } else { + ImSDK.fetchHistoryWithFilters( + toId = targetId, + page = 0, + size = SEARCH_PAGE_SIZE, + keyword = normalized, + ) + } + }.getOrNull() + _searchResults.value = if (remote != null) { + remote.sortedByDescending { it.createdAt } + } else { + cache.searchHistory(targetId, chatType, normalized) + } + } + } + + fun openSearchResult(messageId: String) { + if (!initialized) return + viewModelScope.launch { + val visible = _messages.value.any { it.id == messageId } + if (!visible) { + val located = locateMessagePage(messageId) + if (located == null) { + _events.tryEmit("未找到消息") + return@launch + } + _messages.value = mergeMessages(_messages.value, located) + cache.mergeHistory(targetId, chatType, _messages.value) + } + _focusMessageId.value = messageId + _focusMessageSignal.value = _focusMessageSignal.value + 1 + clearSearch() } } @@ -317,10 +371,16 @@ class ChatViewModel : ViewModel() { fun sendFile(uri: Uri) = sendAttachment(uri, AttachmentKind.FILE) fun clearSearch() { + searchJob?.cancel() + searchJob = null _searchQuery.value = "" _searchResults.value = emptyList() } + fun clearFocusedMessage() { + _focusMessageId.value = null + } + private fun mergeHistory(messages: List): List = messages.distinctBy { it.id }.sortedByDescending { it.createdAt } @@ -485,7 +545,7 @@ class ChatViewModel : ViewModel() { ) AppDependencies.notifyConversationChanged() if (_searchQuery.value.isNotBlank()) { - _searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value) + searchMessages(_searchQuery.value) } requestScrollToBottom() if (message.status.uppercase() != "READ" && message.fromId != currentUserId) { @@ -577,8 +637,20 @@ class ChatViewModel : ViewModel() { } } + private suspend fun locateMessagePage(messageId: String): List? { + return runCatching { + if (chatType == "GROUP") { + ImSDK.locateGroupHistoryPage(targetId, messageId, HISTORY_PAGE_SIZE, LOCATE_MAX_PAGES) + } else { + ImSDK.locateHistoryPage(targetId, messageId, HISTORY_PAGE_SIZE, LOCATE_MAX_PAGES) + } + }.getOrNull() + } + private companion object { const val TAG = "XuqmChatViewModel" const val HISTORY_PAGE_SIZE = 20 + const val SEARCH_PAGE_SIZE = 50 + const val LOCATE_MAX_PAGES = 30 } }