feat(im): add remote chat search and locate

这个提交包含在:
XuqmGroup 2026-04-29 09:54:40 +08:00
父节点 65bdb352bf
当前提交 ce1301fd65
共有 2 个文件被更改,包括 94 次插入9 次删除

查看文件

@ -93,6 +93,8 @@ fun ChatScreen(
val mentionableUsers by viewModel.mentionableUsers.collectAsStateWithLifecycle() val mentionableUsers by viewModel.mentionableUsers.collectAsStateWithLifecycle()
val replyTargetMessage by viewModel.replyTargetMessage.collectAsStateWithLifecycle() val replyTargetMessage by viewModel.replyTargetMessage.collectAsStateWithLifecycle()
val editingMessage by viewModel.editingMessage.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 scrollSignal by viewModel.scrollToBottomSignal.collectAsStateWithLifecycle()
val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle() val isLoadingMore by viewModel.isLoadingMore.collectAsStateWithLifecycle()
val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle() val hasMoreHistory by viewModel.hasMoreHistory.collectAsStateWithLifecycle()
@ -195,6 +197,14 @@ fun ChatScreen(
listState.animateScrollToItem(0) 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) { LaunchedEffect(listState, hasMoreHistory, isLoadingMore, messages.size, searchQuery) {
if (searchQuery.isNotBlank()) return@LaunchedEffect if (searchQuery.isNotBlank()) return@LaunchedEffect
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 } snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }
@ -428,8 +438,8 @@ fun ChatScreen(
if (showSearchBar || searchQuery.isNotBlank()) { if (showSearchBar || searchQuery.isNotBlank()) {
SearchBarField( SearchBarField(
value = searchQuery, value = searchQuery,
onValueChange = viewModel::searchCachedMessages, onValueChange = viewModel::searchMessages,
placeholder = "搜索当前会话本地消息", placeholder = "搜索当前会话消息",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
) )
} }
@ -445,6 +455,7 @@ fun ChatScreen(
message = msg, message = msg,
isOwn = msg.fromId == viewModel.currentUserId, isOwn = msg.fromId == viewModel.currentUserId,
currentUserId = viewModel.currentUserId, currentUserId = viewModel.currentUserId,
onClick = { viewModel.openSearchResult(msg.id) },
onReply = viewModel::startReply, onReply = viewModel::startReply,
onEdit = viewModel::startEdit, onEdit = viewModel::startEdit,
) )
@ -475,6 +486,7 @@ fun ChatScreen(
message = msg, message = msg,
isOwn = msg.fromId == viewModel.currentUserId, isOwn = msg.fromId == viewModel.currentUserId,
currentUserId = viewModel.currentUserId, currentUserId = viewModel.currentUserId,
onClick = {},
onReply = viewModel::startReply, onReply = viewModel::startReply,
onEdit = viewModel::startEdit, onEdit = viewModel::startEdit,
) )
@ -533,6 +545,7 @@ private fun MessageBubble(
message: ImMessage, message: ImMessage,
isOwn: Boolean, isOwn: Boolean,
currentUserId: String, currentUserId: String,
onClick: () -> Unit,
onReply: (ImMessage) -> Unit, onReply: (ImMessage) -> Unit,
onEdit: (ImMessage) -> Unit, onEdit: (ImMessage) -> Unit,
) { ) {
@ -566,7 +579,7 @@ private fun MessageBubble(
.widthIn(max = 280.dp) .widthIn(max = 280.dp)
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
.combinedClickable( .combinedClickable(
onClick = {}, onClick = onClick,
onLongClick = { onLongClick = {
if (isOwn && message.msgType.uppercase() == "TEXT") { if (isOwn && message.msgType.uppercase() == "TEXT") {
onEdit(message) onEdit(message)

查看文件

@ -17,6 +17,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class ChatViewModel : ViewModel() { class ChatViewModel : ViewModel() {
@ -40,6 +42,12 @@ class ChatViewModel : ViewModel() {
private val _searchResults = MutableStateFlow<List<ImMessage>>(emptyList()) private val _searchResults = MutableStateFlow<List<ImMessage>>(emptyList())
val searchResults: StateFlow<List<ImMessage>> = _searchResults val searchResults: StateFlow<List<ImMessage>> = _searchResults
private val _focusMessageId = MutableStateFlow<String?>(null)
val focusMessageId: StateFlow<String?> = _focusMessageId
private val _focusMessageSignal = MutableStateFlow(0L)
val focusMessageSignal: StateFlow<Long> = _focusMessageSignal
private val _draftText = MutableStateFlow("") private val _draftText = MutableStateFlow("")
val draftText: StateFlow<String> = _draftText val draftText: StateFlow<String> = _draftText
@ -65,6 +73,7 @@ class ChatViewModel : ViewModel() {
private var nextHistoryPage = 0 private var nextHistoryPage = 0
private var initialized = false private var initialized = false
private val pendingDeliveryTimeouts = mutableMapOf<String, kotlinx.coroutines.Job>() private val pendingDeliveryTimeouts = mutableMapOf<String, kotlinx.coroutines.Job>()
private var searchJob: Job? = null
private val listener = object : ImEventListener { private val listener = object : ImEventListener {
override fun onMessage(message: ImMessage) { override fun onMessage(message: ImMessage) {
@ -229,12 +238,57 @@ class ChatViewModel : ViewModel() {
} }
} }
fun searchCachedMessages(query: String) { fun searchMessages(query: String) {
_searchQuery.value = query _searchQuery.value = query
_searchResults.value = if (query.isBlank()) { searchJob?.cancel()
emptyList() if (query.isBlank() || !initialized) {
} else { _searchResults.value = emptyList()
cache.searchHistory(targetId, chatType, query) 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 sendFile(uri: Uri) = sendAttachment(uri, AttachmentKind.FILE)
fun clearSearch() { fun clearSearch() {
searchJob?.cancel()
searchJob = null
_searchQuery.value = "" _searchQuery.value = ""
_searchResults.value = emptyList() _searchResults.value = emptyList()
} }
fun clearFocusedMessage() {
_focusMessageId.value = null
}
private fun mergeHistory(messages: List<ImMessage>): List<ImMessage> = private fun mergeHistory(messages: List<ImMessage>): List<ImMessage> =
messages.distinctBy { it.id }.sortedByDescending { it.createdAt } messages.distinctBy { it.id }.sortedByDescending { it.createdAt }
@ -485,7 +545,7 @@ class ChatViewModel : ViewModel() {
) )
AppDependencies.notifyConversationChanged() AppDependencies.notifyConversationChanged()
if (_searchQuery.value.isNotBlank()) { if (_searchQuery.value.isNotBlank()) {
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value) searchMessages(_searchQuery.value)
} }
requestScrollToBottom() requestScrollToBottom()
if (message.status.uppercase() != "READ" && message.fromId != currentUserId) { if (message.status.uppercase() != "READ" && message.fromId != currentUserId) {
@ -577,8 +637,20 @@ class ChatViewModel : ViewModel() {
} }
} }
private suspend fun locateMessagePage(messageId: String): List<ImMessage>? {
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 { private companion object {
const val TAG = "XuqmChatViewModel" const val TAG = "XuqmChatViewModel"
const val HISTORY_PAGE_SIZE = 20 const val HISTORY_PAGE_SIZE = 20
const val SEARCH_PAGE_SIZE = 50
const val LOCATE_MAX_PAGES = 30
} }
} }