feat(im): add remote chat search and locate
这个提交包含在:
父节点
65bdb352bf
当前提交
ce1301fd65
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户