feat(chat): 添加聊天界面和本地缓存功能
- 新增 ChatScreen 和 ChatViewModel 实现聊天界面 - 新增 ConversationScreen 和 ConversationViewModel 实现会话列表 - 添加 LocalImCache 类实现本地消息和会话缓存 - 集成 IM SDK 的消息发送、接收和历史记录功能 - 实现消息搜索和分页加载功能 - 添加会话置顶、免打扰等管理功能 - 在 AppDependencies 中注册本地缓存依赖
这个提交包含在:
父节点
64fefd1bbb
当前提交
efe2a32a00
@ -0,0 +1,160 @@
|
||||
package com.xuqm.sdk.sample.data.local
|
||||
|
||||
import android.content.Context
|
||||
import com.xuqm.sdk.im.model.ConversationData
|
||||
import com.xuqm.sdk.im.model.ImMessage
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class LocalImCache(context: Context) {
|
||||
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun loadConversations(): List<ConversationData> = readConversationList(KEY_CONVERSATIONS)
|
||||
|
||||
fun saveConversations(conversations: List<ConversationData>) {
|
||||
prefs.edit().putString(KEY_CONVERSATIONS, serializeConversationList(conversations)).apply()
|
||||
}
|
||||
|
||||
fun loadHistory(targetId: String, chatType: String): List<ImMessage> =
|
||||
readMessageList(historyKey(targetId, chatType))
|
||||
|
||||
fun saveHistory(targetId: String, chatType: String, messages: List<ImMessage>) {
|
||||
prefs.edit().putString(historyKey(targetId, chatType), serializeMessageList(messages)).apply()
|
||||
}
|
||||
|
||||
fun getHistoryPage(targetId: String, chatType: String, page: Int, size: Int): List<ImMessage> {
|
||||
val history = loadHistory(targetId, chatType).sortedByDescending { it.createdAt }
|
||||
val fromIndex = page * size
|
||||
if (fromIndex >= history.size) return emptyList()
|
||||
return history.drop(fromIndex).take(size)
|
||||
}
|
||||
|
||||
fun searchHistory(
|
||||
targetId: String,
|
||||
chatType: String,
|
||||
keyword: String,
|
||||
): List<ImMessage> {
|
||||
val normalized = keyword.trim()
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
return loadHistory(targetId, chatType)
|
||||
.filter { it.matches(normalized) }
|
||||
.sortedByDescending { it.createdAt }
|
||||
}
|
||||
|
||||
fun mergeHistory(targetId: String, chatType: String, messages: List<ImMessage>) {
|
||||
val merged = (loadHistory(targetId, chatType) + messages)
|
||||
.distinctBy { it.id }
|
||||
.sortedByDescending { it.createdAt }
|
||||
saveHistory(targetId, chatType, merged)
|
||||
}
|
||||
|
||||
private fun readConversationList(key: String): List<ConversationData> {
|
||||
val raw = prefs.getString(key, null) ?: return emptyList()
|
||||
return runCatching {
|
||||
val array = JSONArray(raw)
|
||||
buildList {
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.getJSONObject(i)
|
||||
add(
|
||||
ConversationData(
|
||||
targetId = obj.optString("targetId"),
|
||||
chatType = obj.optString("chatType"),
|
||||
lastMsgContent = obj.optString("lastMsgContent").takeIf { it.isNotBlank() },
|
||||
lastMsgType = obj.optString("lastMsgType").takeIf { it.isNotBlank() },
|
||||
lastMsgTime = obj.optLong("lastMsgTime"),
|
||||
unreadCount = obj.optInt("unreadCount"),
|
||||
isMuted = obj.optBoolean("isMuted"),
|
||||
isPinned = obj.optBoolean("isPinned"),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun readMessageList(key: String): List<ImMessage> {
|
||||
val raw = prefs.getString(key, null) ?: return emptyList()
|
||||
return runCatching {
|
||||
val array = JSONArray(raw)
|
||||
buildList {
|
||||
for (i in 0 until array.length()) {
|
||||
val obj = array.getJSONObject(i)
|
||||
add(
|
||||
ImMessage(
|
||||
id = obj.optString("id"),
|
||||
appId = obj.optString("appId"),
|
||||
fromId = obj.optString("fromId"),
|
||||
toId = obj.optString("toId"),
|
||||
chatType = obj.optString("chatType"),
|
||||
msgType = obj.optString("msgType"),
|
||||
content = obj.optString("content"),
|
||||
status = obj.optString("status"),
|
||||
mentionedUserIds = obj.optString("mentionedUserIds").takeIf { it.isNotBlank() },
|
||||
createdAt = obj.optLong("createdAt"),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun serializeConversationList(conversations: List<ConversationData>): String {
|
||||
val array = JSONArray()
|
||||
conversations.forEach { conversation ->
|
||||
array.put(
|
||||
JSONObject().apply {
|
||||
put("targetId", conversation.targetId)
|
||||
put("chatType", conversation.chatType)
|
||||
put("lastMsgContent", conversation.lastMsgContent ?: "")
|
||||
put("lastMsgType", conversation.lastMsgType ?: "")
|
||||
put("lastMsgTime", conversation.lastMsgTime)
|
||||
put("unreadCount", conversation.unreadCount)
|
||||
put("isMuted", conversation.isMuted)
|
||||
put("isPinned", conversation.isPinned)
|
||||
}
|
||||
)
|
||||
}
|
||||
return array.toString()
|
||||
}
|
||||
|
||||
private fun serializeMessageList(messages: List<ImMessage>): String {
|
||||
val array = JSONArray()
|
||||
messages.forEach { message ->
|
||||
array.put(
|
||||
JSONObject().apply {
|
||||
put("id", message.id)
|
||||
put("appId", message.appId)
|
||||
put("fromId", message.fromId)
|
||||
put("toId", message.toId)
|
||||
put("chatType", message.chatType)
|
||||
put("msgType", message.msgType)
|
||||
put("content", message.content)
|
||||
put("status", message.status)
|
||||
put("mentionedUserIds", message.mentionedUserIds ?: "")
|
||||
put("createdAt", message.createdAt)
|
||||
}
|
||||
)
|
||||
}
|
||||
return array.toString()
|
||||
}
|
||||
|
||||
private fun ImMessage.matches(keyword: String): Boolean {
|
||||
val plainText = when (msgType.uppercase()) {
|
||||
"TEXT" -> runCatching { JSONObject(content).optString("text") }.getOrDefault(content)
|
||||
"NOTIFY" -> runCatching { JSONObject(content).optString("content") }.getOrDefault(content)
|
||||
else -> content
|
||||
}
|
||||
return plainText.contains(keyword, ignoreCase = true) ||
|
||||
fromId.contains(keyword, ignoreCase = true) ||
|
||||
toId.contains(keyword, ignoreCase = true) ||
|
||||
msgType.contains(keyword, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun historyKey(targetId: String, chatType: String) = "history_${chatType}_$targetId"
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "xuqm_demo_im_cache"
|
||||
const val KEY_CONVERSATIONS = "conversations"
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,9 @@ package com.xuqm.sdk.sample.di
|
||||
|
||||
import android.content.Context
|
||||
import com.xuqm.sdk.sample.data.repo.AuthRepository
|
||||
import com.xuqm.sdk.sample.data.local.LocalContactCache
|
||||
import com.xuqm.sdk.sample.data.repo.EnvironmentRepository
|
||||
import com.xuqm.sdk.sample.data.local.LocalImCache
|
||||
|
||||
object AppDependencies {
|
||||
|
||||
@ -10,10 +12,16 @@ object AppDependencies {
|
||||
private set
|
||||
lateinit var environmentRepository: EnvironmentRepository
|
||||
private set
|
||||
lateinit var localImCache: LocalImCache
|
||||
private set
|
||||
lateinit var localContactCache: LocalContactCache
|
||||
private set
|
||||
|
||||
fun init(context: Context) {
|
||||
environmentRepository = EnvironmentRepository(context)
|
||||
environmentRepository.current()
|
||||
localImCache = LocalImCache(context)
|
||||
localContactCache = LocalContactCache(context)
|
||||
authRepository = AuthRepository(context)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ package com.xuqm.sdk.sample.ui.chat
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@ -18,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@ -41,10 +41,11 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.xuqm.sdk.im.model.ImMessage
|
||||
import com.xuqm.sdk.im.ImSDK
|
||||
import com.xuqm.sdk.ui.InitialAvatar
|
||||
import com.xuqm.sdk.im.model.ImMessage
|
||||
import com.xuqm.sdk.sample.ui.common.ConnectionStatusBanner
|
||||
import com.xuqm.sdk.ui.InitialAvatar
|
||||
import com.xuqm.sdk.ui.SearchBarField
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@ -58,18 +59,24 @@ fun ChatScreen(
|
||||
viewModel: ChatViewModel = viewModel(),
|
||||
) {
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
|
||||
val searchResults by viewModel.searchResults.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) }
|
||||
LaunchedEffect(scrollSignal) {
|
||||
if (messages.isNotEmpty()) listState.animateScrollToItem(0)
|
||||
if (messages.isNotEmpty() && searchQuery.isBlank()) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(listState, hasMoreHistory, isLoadingMore, messages.size) {
|
||||
LaunchedEffect(listState, hasMoreHistory, isLoadingMore, messages.size, searchQuery) {
|
||||
if (searchQuery.isNotBlank()) return@LaunchedEffect
|
||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }
|
||||
.distinctUntilChanged()
|
||||
.collect { lastVisibleIndex ->
|
||||
@ -94,6 +101,16 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
showSearchBar = !showSearchBar
|
||||
if (!showSearchBar) {
|
||||
viewModel.clearSearch()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(Icons.Default.Search, contentDescription = "搜索")
|
||||
}
|
||||
if (chatType == "GROUP" && onGroupSettings != null) {
|
||||
IconButton(onClick = onGroupSettings) {
|
||||
Icon(Icons.Default.Settings, contentDescription = null)
|
||||
@ -129,29 +146,71 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
items(messages, key = { it.id }) { msg ->
|
||||
MessageBubble(
|
||||
message = msg,
|
||||
isOwn = msg.fromId == viewModel.currentUserId,
|
||||
if (showSearchBar || searchQuery.isNotBlank()) {
|
||||
SearchBarField(
|
||||
value = searchQuery,
|
||||
onValueChange = viewModel::searchCachedMessages,
|
||||
placeholder = "搜索当前会话本地消息",
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
if (isLoadingMore) {
|
||||
item(key = "loading-more") {
|
||||
Text(
|
||||
"加载历史中...",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
|
||||
if (searchQuery.isNotBlank()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
) {
|
||||
items(searchResults, key = { it.id }) { msg ->
|
||||
MessageBubble(
|
||||
message = msg,
|
||||
isOwn = msg.fromId == viewModel.currentUserId,
|
||||
)
|
||||
}
|
||||
if (searchResults.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"没有找到匹配的本地消息",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
) {
|
||||
items(messages, key = { it.id }) { msg ->
|
||||
MessageBubble(
|
||||
message = msg,
|
||||
isOwn = msg.fromId == viewModel.currentUserId,
|
||||
)
|
||||
}
|
||||
if (isLoadingMore) {
|
||||
item(key = "loading-more") {
|
||||
Text(
|
||||
"加载历史中...",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,12 +5,14 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.xuqm.sdk.im.ImSDK
|
||||
import com.xuqm.sdk.im.listener.ImEventListener
|
||||
import com.xuqm.sdk.im.model.ImMessage
|
||||
import com.xuqm.sdk.sample.di.AppDependencies
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
|
||||
private val cache = AppDependencies.localImCache
|
||||
private val _messages = MutableStateFlow<List<ImMessage>>(emptyList())
|
||||
val messages: StateFlow<List<ImMessage>> = _messages
|
||||
|
||||
@ -23,6 +25,12 @@ class ChatViewModel : ViewModel() {
|
||||
private val _scrollToBottomSignal = MutableStateFlow(0L)
|
||||
val scrollToBottomSignal: StateFlow<Long> = _scrollToBottomSignal
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery
|
||||
|
||||
private val _searchResults = MutableStateFlow<List<ImMessage>>(emptyList())
|
||||
val searchResults: StateFlow<List<ImMessage>> = _searchResults
|
||||
|
||||
val currentUserId: String get() = ImSDK.currentUserId
|
||||
|
||||
private lateinit var targetId: String
|
||||
@ -34,6 +42,10 @@ class ChatViewModel : ViewModel() {
|
||||
override fun onMessage(message: ImMessage) {
|
||||
if (isRelevant(message)) {
|
||||
prependMessage(message)
|
||||
cache.mergeHistory(targetId, chatType, _messages.value)
|
||||
if (_searchQuery.value.isNotBlank()) {
|
||||
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
|
||||
}
|
||||
requestScrollToBottom()
|
||||
}
|
||||
}
|
||||
@ -41,6 +53,10 @@ class ChatViewModel : ViewModel() {
|
||||
override fun onGroupMessage(message: ImMessage) {
|
||||
if (isRelevant(message)) {
|
||||
prependMessage(message)
|
||||
cache.mergeHistory(targetId, chatType, _messages.value)
|
||||
if (_searchQuery.value.isNotBlank()) {
|
||||
_searchResults.value = cache.searchHistory(targetId, chatType, _searchQuery.value)
|
||||
}
|
||||
requestScrollToBottom()
|
||||
}
|
||||
}
|
||||
@ -52,9 +68,11 @@ class ChatViewModel : ViewModel() {
|
||||
this.chatType = chatType
|
||||
nextHistoryPage = 0
|
||||
initialized = true
|
||||
_messages.value = emptyList()
|
||||
_messages.value = cache.getHistoryPage(targetId, chatType, 0, HISTORY_PAGE_SIZE)
|
||||
_hasMoreHistory.value = true
|
||||
_isLoadingMore.value = false
|
||||
_searchQuery.value = ""
|
||||
_searchResults.value = emptyList()
|
||||
ImSDK.addListener(listener)
|
||||
loadInitialHistory()
|
||||
viewModelScope.launch {
|
||||
@ -86,18 +104,31 @@ class ChatViewModel : ViewModel() {
|
||||
nextHistoryPage += 1
|
||||
}
|
||||
_hasMoreHistory.value = history.size >= HISTORY_PAGE_SIZE
|
||||
if (history.isNotEmpty()) {
|
||||
cache.saveHistory(targetId, chatType, mergeHistory(_messages.value))
|
||||
}
|
||||
_isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchHistory(page: Int): List<ImMessage> {
|
||||
return if (chatType == "GROUP") {
|
||||
val remote = if (chatType == "GROUP") {
|
||||
runCatching { ImSDK.fetchGroupHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
|
||||
.getOrDefault(emptyList())
|
||||
.getOrNull()
|
||||
} else {
|
||||
runCatching { ImSDK.fetchHistory(targetId, page = page, size = HISTORY_PAGE_SIZE) }
|
||||
.getOrDefault(emptyList())
|
||||
.getOrNull()
|
||||
}
|
||||
if (remote != null) {
|
||||
val normalized = remote.sortedByDescending { it.createdAt }
|
||||
if (page == 0) {
|
||||
cache.saveHistory(targetId, chatType, normalized)
|
||||
} else {
|
||||
cache.mergeHistory(targetId, chatType, normalized)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
return cache.getHistoryPage(targetId, chatType, page, HISTORY_PAGE_SIZE)
|
||||
}
|
||||
|
||||
fun sendText(content: String) {
|
||||
@ -105,11 +136,28 @@ class ChatViewModel : ViewModel() {
|
||||
ImSDK.sendMessage(targetId, chatType, "TEXT", content)
|
||||
}
|
||||
|
||||
fun searchCachedMessages(query: String) {
|
||||
_searchQuery.value = query
|
||||
_searchResults.value = if (query.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
cache.searchHistory(targetId, chatType, query)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSearch() {
|
||||
_searchQuery.value = ""
|
||||
_searchResults.value = emptyList()
|
||||
}
|
||||
|
||||
private fun prependMessage(message: ImMessage) {
|
||||
if (_messages.value.any { it.id == message.id }) return
|
||||
_messages.value = listOf(message) + _messages.value
|
||||
}
|
||||
|
||||
private fun mergeHistory(messages: List<ImMessage>): List<ImMessage> =
|
||||
messages.distinctBy { it.id }.sortedByDescending { it.createdAt }
|
||||
|
||||
private fun requestScrollToBottom() {
|
||||
_scrollToBottomSignal.value = _scrollToBottomSignal.value + 1
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil3.compose.AsyncImage
|
||||
import com.xuqm.sdk.im.model.ConversationData
|
||||
import com.xuqm.sdk.ui.InitialAvatar
|
||||
import com.xuqm.sdk.ui.SearchBarField
|
||||
import com.xuqm.sdk.utils.TimeFormatters
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ -45,20 +46,52 @@ fun ConversationScreen(
|
||||
) {
|
||||
val conversations by viewModel.conversations.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
var query by remember { mutableStateOf("") }
|
||||
val filtered = remember(conversations, query) {
|
||||
conversations.filter { conversation ->
|
||||
if (query.isBlank()) return@filter true
|
||||
val keyword = query.trim()
|
||||
conversation.targetId.contains(keyword, ignoreCase = true) ||
|
||||
(conversation.lastMsgContent ?: "").contains(keyword, ignoreCase = true) ||
|
||||
conversation.chatType.contains(keyword, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(conversations, key = { "${it.chatType}_${it.targetId}" }) { conv ->
|
||||
ConversationItem(
|
||||
conversation = conv,
|
||||
onClick = { onOpenChat(conv.targetId, conv.chatType, conv.targetId) },
|
||||
onPinToggle = {
|
||||
scope.launch { viewModel.setPinned(conv.targetId, conv.chatType, !conv.isPinned) }
|
||||
},
|
||||
onMuteToggle = {
|
||||
scope.launch { viewModel.setMuted(conv.targetId, conv.chatType, !conv.isMuted) }
|
||||
},
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
SearchBarField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = "搜索会话 / 最近消息",
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
if (filtered.isEmpty()) {
|
||||
Text(
|
||||
"没有匹配的会话",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(start = 72.dp))
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
) {
|
||||
items(filtered, key = { "${it.chatType}_${it.targetId}" }) { conv ->
|
||||
ConversationItem(
|
||||
conversation = conv,
|
||||
onClick = { onOpenChat(conv.targetId, conv.chatType, conv.targetId) },
|
||||
onPinToggle = {
|
||||
scope.launch { viewModel.setPinned(conv.targetId, conv.chatType, !conv.isPinned) }
|
||||
},
|
||||
onMuteToggle = {
|
||||
scope.launch { viewModel.setMuted(conv.targetId, conv.chatType, !conv.isMuted) }
|
||||
},
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(start = 72.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,12 +6,14 @@ import com.xuqm.sdk.im.ImSDK
|
||||
import com.xuqm.sdk.im.listener.ImEventListener
|
||||
import com.xuqm.sdk.im.model.ConversationData
|
||||
import com.xuqm.sdk.im.model.ImMessage
|
||||
import com.xuqm.sdk.sample.di.AppDependencies
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ConversationViewModel : ViewModel() {
|
||||
|
||||
private val cache = AppDependencies.localImCache
|
||||
private val _conversations = MutableStateFlow<List<ConversationData>>(emptyList())
|
||||
val conversations: StateFlow<List<ConversationData>> = _conversations
|
||||
|
||||
@ -22,6 +24,7 @@ class ConversationViewModel : ViewModel() {
|
||||
|
||||
init {
|
||||
ImSDK.addListener(listener)
|
||||
_conversations.value = cache.loadConversations().sortedByDescending { it.lastMsgTime }
|
||||
refresh()
|
||||
}
|
||||
|
||||
@ -29,7 +32,13 @@ class ConversationViewModel : ViewModel() {
|
||||
viewModelScope.launch {
|
||||
runCatching { ImSDK.listConversations() }
|
||||
.onSuccess { list ->
|
||||
_conversations.value = list.sortedByDescending { it.lastMsgTime }
|
||||
val sorted = list.sortedByDescending { it.lastMsgTime }
|
||||
cache.saveConversations(sorted)
|
||||
_conversations.value = sorted
|
||||
}
|
||||
.onFailure {
|
||||
val cached = cache.loadConversations().sortedByDescending { it.lastMsgTime }
|
||||
if (cached.isNotEmpty()) _conversations.value = cached
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户