Add chat bulk actions and avatar cache

This commit is contained in:
sevenhill
2026-07-05 17:49:08 +03:00
parent 81dc918a50
commit 4db643b028
16 changed files with 982 additions and 35 deletions
+2 -2
View File
@@ -22,8 +22,8 @@ android {
applicationId = "xyz.kusoft.qmax"
minSdk = 26
targetSdk = 36
versionCode = 49
versionName = "0.1.48"
versionCode = 51
versionName = "0.1.50"
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
@@ -321,11 +321,18 @@ private fun LoginScreen(vm: QMaxViewModel) {
}
}
private enum class ChatBulkUiAction {
ClearHistory,
Delete
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChatListScreen(vm: QMaxViewModel) {
val state by vm.state
var menuOpen by remember { mutableStateOf(false) }
var selectionMenuOpen by remember { mutableStateOf(false) }
var pendingBulkAction by remember { mutableStateOf<ChatBulkUiAction?>(null) }
var searchMode by remember { mutableStateOf(false) }
var newChatOpen by remember { mutableStateOf(false) }
val filteredChats = remember(state.chats, state.searchQuery) {
@@ -350,9 +357,69 @@ private fun ChatListScreen(vm: QMaxViewModel) {
onLogout = vm::logout,
onSearch = vm::updateSearchQuery,
onOpenChat = vm::openChat,
onNewChat = { newChatOpen = true }
onNewChat = { newChatOpen = true },
onCacheAvatar = vm::cacheAvatar,
selectionMenuOpen = selectionMenuOpen,
onSelectionMenuOpenChange = { selectionMenuOpen = it },
onClearSelection = vm::clearChatSelection,
onToggleChatSelection = vm::toggleChatSelection,
onBeginChatSelection = vm::beginChatSelection,
onClearSelectedHistory = {
selectionMenuOpen = false
pendingBulkAction = ChatBulkUiAction.ClearHistory
},
onDeleteSelectedChats = {
selectionMenuOpen = false
pendingBulkAction = ChatBulkUiAction.Delete
}
)
pendingBulkAction?.let { action ->
val selectedCount = state.selectedChatIds.size
AlertDialog(
onDismissRequest = { pendingBulkAction = null },
title = {
Text(
when (action) {
ChatBulkUiAction.ClearHistory -> "Очистить историю"
ChatBulkUiAction.Delete -> "Удалить чаты"
}
)
},
text = {
Text(
when (action) {
ChatBulkUiAction.ClearHistory -> "Очистить историю в выбранных чатах: $selectedCount?"
ChatBulkUiAction.Delete -> "Удалить выбранные чаты: $selectedCount?"
}
)
},
confirmButton = {
TextButton(
onClick = {
pendingBulkAction = null
when (action) {
ChatBulkUiAction.ClearHistory -> vm.clearSelectedChatHistories()
ChatBulkUiAction.Delete -> vm.deleteSelectedChats()
}
}
) {
Text(
when (action) {
ChatBulkUiAction.ClearHistory -> "Очистить"
ChatBulkUiAction.Delete -> "Удалить"
}
)
}
},
dismissButton = {
TextButton(onClick = { pendingBulkAction = null }) {
Text("Отмена")
}
}
)
}
if (newChatOpen) {
NewChatDialog(
vm = vm,
@@ -483,7 +550,9 @@ private fun ChatListScreen(vm: QMaxViewModel) {
ChatRow(
chat = chat,
session = state.session,
cachedAvatarPaths = state.cachedAvatarPaths,
draftText = state.drafts[chat.id],
onCacheAvatar = vm::cacheAvatar,
onClick = { vm.openChat(chat) }
)
HorizontalDivider(color = Color(0xFFE9EEF2), thickness = 0.6.dp, modifier = Modifier.padding(start = 74.dp))
@@ -516,8 +585,17 @@ private fun TelegramChatListContent(
onLogout: () -> Unit,
onSearch: (String) -> Unit,
onOpenChat: (ChatDto) -> Unit,
onNewChat: () -> Unit
onNewChat: () -> Unit,
onCacheAvatar: (String) -> Unit,
selectionMenuOpen: Boolean,
onSelectionMenuOpenChange: (Boolean) -> Unit,
onClearSelection: () -> Unit,
onToggleChatSelection: (String) -> Unit,
onBeginChatSelection: (String) -> Unit,
onClearSelectedHistory: () -> Unit,
onDeleteSelectedChats: () -> Unit
) {
val selectedCount = state.selectedChatIds.size
Box(Modifier.fillMaxSize().background(Color.White)) {
Column(
Modifier
@@ -525,14 +603,25 @@ private fun TelegramChatListContent(
.statusBarsPadding()
.background(Color.White)
) {
TelegramLikeHeader(
title = if (state.chatListConnectionIssue) "Соединение..." else "QMAX",
menuOpen = menuOpen,
onMenuOpenChange = onMenuOpenChange,
onRefresh = onRefresh,
onCheckUpdates = onCheckUpdates,
onLogout = onLogout
)
if (selectedCount > 0) {
ChatSelectionHeader(
selectedCount = selectedCount,
menuOpen = selectionMenuOpen,
onMenuOpenChange = onSelectionMenuOpenChange,
onClearSelection = onClearSelection,
onClearHistory = onClearSelectedHistory,
onDelete = onDeleteSelectedChats
)
} else {
TelegramLikeHeader(
title = if (state.chatListConnectionIssue) "Соединение..." else "QMAX",
menuOpen = menuOpen,
onMenuOpenChange = onMenuOpenChange,
onRefresh = onRefresh,
onCheckUpdates = onCheckUpdates,
onLogout = onLogout
)
}
ChatListSearchBar(query = state.searchQuery, onQuery = onSearch)
state.updateMessage?.let {
Text(
@@ -557,8 +646,19 @@ private fun TelegramChatListContent(
TelegramChatRow(
chat = chat,
session = state.session,
cachedAvatarPaths = state.cachedAvatarPaths,
draftText = state.drafts[chat.id],
onClick = { onOpenChat(chat) }
onCacheAvatar = onCacheAvatar,
isSelected = chat.id in state.selectedChatIds,
selectionActive = selectedCount > 0,
onLongClick = { onBeginChatSelection(chat.id) },
onClick = {
if (selectedCount > 0) {
onToggleChatSelection(chat.id)
} else {
onOpenChat(chat)
}
}
)
}
}
@@ -674,6 +774,81 @@ private fun QMaxHeaderAvatarStack() {
}
}
@Composable
private fun ChatSelectionHeader(
selectedCount: Int,
menuOpen: Boolean,
onMenuOpenChange: (Boolean) -> Unit,
onClearSelection: () -> Unit,
onClearHistory: () -> Unit,
onDelete: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 12.dp, top = 14.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = onClearSelection,
modifier = Modifier.size(52.dp)
) {
Icon(
Icons.Filled.Close,
contentDescription = "Снять выделение",
tint = QMaxText,
modifier = Modifier.size(30.dp)
)
}
Text(
selectedCount.toString(),
color = QMaxText,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
Box {
IconButton(
onClick = { onMenuOpenChange(true) },
modifier = Modifier.size(52.dp)
) {
Icon(
Icons.Filled.MoreVert,
contentDescription = "Действия с выбранными чатами",
tint = QMaxText,
modifier = Modifier.size(30.dp)
)
}
DropdownMenu(
expanded = menuOpen,
onDismissRequest = { onMenuOpenChange(false) }
) {
DropdownMenuItem(
text = { Text("Очистить историю") },
onClick = {
onMenuOpenChange(false)
onClearHistory()
}
)
DropdownMenuItem(
text = { Text("Удалить") },
leadingIcon = {
Icon(
Icons.Filled.Delete,
contentDescription = null,
tint = Color(0xFFD84343)
)
},
onClick = {
onMenuOpenChange(false)
onDelete()
}
)
}
}
}
}
@Composable
private fun ChatListSearchBar(query: String, onQuery: (String) -> Unit) {
Surface(
@@ -716,8 +891,19 @@ private fun ChatListSearchBar(query: String, onQuery: (String) -> Unit) {
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun TelegramChatRow(chat: ChatDto, session: QMaxSession?, draftText: String?, onClick: () -> Unit) {
private fun TelegramChatRow(
chat: ChatDto,
session: QMaxSession?,
cachedAvatarPaths: Map<String, String>,
draftText: String?,
onCacheAvatar: (String) -> Unit,
isSelected: Boolean,
selectionActive: Boolean,
onLongClick: () -> Unit,
onClick: () -> Unit
) {
val hasDraft = !draftText.isNullOrBlank()
val preview = if (hasDraft) {
"Черновик: ${draftText.trim()}"
@@ -726,15 +912,50 @@ private fun TelegramChatRow(chat: ChatDto, session: QMaxSession?, draftText: Str
}
val isMediaPreview = !hasDraft && isGenericMediaPreview(chat.lastMessagePreview)
val unreadText = chat.unreadCount.coerceAtMost(999).toString()
val rowBackground = when {
isSelected -> Color(0xFFEAF7FF)
selectionActive -> Color(0xFFFAFCFD)
else -> Color.Transparent
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.background(rowBackground)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
)
.padding(horizontal = 26.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(title = chat.title, avatarUrl = chat.avatarUrl, session = session, size = 58.dp)
Box {
Avatar(
title = chat.title,
avatarUrl = chat.avatarUrl,
session = session,
size = 58.dp,
cachedAvatarPaths = cachedAvatarPaths,
onCacheAvatar = onCacheAvatar
)
if (isSelected) {
Surface(
color = QMaxBlue,
shape = CircleShape,
border = androidx.compose.foundation.BorderStroke(2.dp, Color.White),
modifier = Modifier
.align(Alignment.BottomEnd)
.size(22.dp)
) {
Icon(
Icons.Filled.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(3.dp)
)
}
}
}
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -987,7 +1208,14 @@ private fun ServiceBanner(vm: QMaxViewModel) {
}
@Composable
private fun ChatRow(chat: ChatDto, session: QMaxSession?, draftText: String?, onClick: () -> Unit) {
private fun ChatRow(
chat: ChatDto,
session: QMaxSession?,
cachedAvatarPaths: Map<String, String>,
draftText: String?,
onCacheAvatar: (String) -> Unit,
onClick: () -> Unit
) {
val hasDraft = !draftText.isNullOrBlank()
val preview = if (hasDraft) {
"Черновик: ${draftText.trim()}"
@@ -1003,7 +1231,14 @@ private fun ChatRow(chat: ChatDto, session: QMaxSession?, draftText: String?, on
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(title = chat.title, avatarUrl = chat.avatarUrl, session = session, size = 52.dp)
Avatar(
title = chat.title,
avatarUrl = chat.avatarUrl,
session = session,
size = 52.dp,
cachedAvatarPaths = cachedAvatarPaths,
onCacheAvatar = onCacheAvatar
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -1340,7 +1575,14 @@ private fun ChatScreen(vm: QMaxViewModel) {
)
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
Avatar(title = chat.title, avatarUrl = chat.avatarUrl, session = session, size = 38.dp)
Avatar(
title = chat.title,
avatarUrl = chat.avatarUrl,
session = session,
size = 38.dp,
cachedAvatarPaths = state.cachedAvatarPaths,
onCacheAvatar = vm::cacheAvatar
)
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text(chat.title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold)
@@ -1566,6 +1808,9 @@ private fun ChatScreen(vm: QMaxViewModel) {
ForwardMessageDialog(
chats = state.chats,
selectedChatId = chat.id,
session = session,
cachedAvatarPaths = state.cachedAvatarPaths,
onCacheAvatar = vm::cacheAvatar,
onSelect = vm::forwardTo,
onClose = vm::closeForwardPicker
)
@@ -1576,6 +1821,9 @@ private fun ChatScreen(vm: QMaxViewModel) {
private fun ForwardMessageDialog(
chats: List<ChatDto>,
selectedChatId: String?,
session: QMaxSession?,
cachedAvatarPaths: Map<String, String>,
onCacheAvatar: (String) -> Unit,
onSelect: (ChatDto) -> Unit,
onClose: () -> Unit
) {
@@ -1603,6 +1851,9 @@ private fun ForwardMessageDialog(
ForwardChatRow(
chat = chat,
isCurrent = chat.id == selectedChatId,
session = session,
cachedAvatarPaths = cachedAvatarPaths,
onCacheAvatar = onCacheAvatar,
onClick = { onSelect(chat) }
)
HorizontalDivider(color = Color(0xFFE9EEF2), thickness = 0.6.dp, modifier = Modifier.padding(start = 56.dp))
@@ -1614,7 +1865,14 @@ private fun ForwardMessageDialog(
}
@Composable
private fun ForwardChatRow(chat: ChatDto, isCurrent: Boolean, onClick: () -> Unit) {
private fun ForwardChatRow(
chat: ChatDto,
isCurrent: Boolean,
session: QMaxSession?,
cachedAvatarPaths: Map<String, String>,
onCacheAvatar: (String) -> Unit,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -1622,7 +1880,14 @@ private fun ForwardChatRow(chat: ChatDto, isCurrent: Boolean, onClick: () -> Uni
.padding(horizontal = 2.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(title = chat.title, size = 44.dp)
Avatar(
title = chat.title,
avatarUrl = chat.avatarUrl,
session = session,
size = 44.dp,
cachedAvatarPaths = cachedAvatarPaths,
onCacheAvatar = onCacheAvatar
)
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -3011,7 +3276,9 @@ private fun Avatar(
title: String,
size: androidx.compose.ui.unit.Dp,
avatarUrl: String? = null,
session: QMaxSession? = null
session: QMaxSession? = null,
cachedAvatarPaths: Map<String, String> = emptyMap(),
onCacheAvatar: (String) -> Unit = {}
) {
val resolvedAvatarUrl = remember(avatarUrl, session?.serverUrl) {
avatarUrl?.takeIf { it.isNotBlank() }?.let { url ->
@@ -3024,14 +3291,13 @@ private fun Avatar(
}
}
}
val avatarModel = if (resolvedAvatarUrl != null &&
session != null &&
resolvedAvatarUrl.startsWith(session.serverUrl.trimEnd('/'), ignoreCase = true)
) {
imageRequest(resolvedAvatarUrl, session.accessToken)
} else {
resolvedAvatarUrl
val cachedAvatarPath = resolvedAvatarUrl?.let(cachedAvatarPaths::get)
LaunchedEffect(resolvedAvatarUrl, cachedAvatarPath) {
if (resolvedAvatarUrl != null && cachedAvatarPath == null) {
onCacheAvatar(resolvedAvatarUrl)
}
}
val avatarModel = cachedAvatarPath?.let(::File)
Box(
Modifier
@@ -1,6 +1,7 @@
package xyz.kusoft.qmax.core
import android.content.Context
import xyz.kusoft.qmax.core.local.AvatarDiskCache
import xyz.kusoft.qmax.core.local.AttachmentDiskCache
import xyz.kusoft.qmax.core.local.LocalMessageCache
import xyz.kusoft.qmax.core.network.QMaxApi
@@ -12,6 +13,7 @@ class QMaxContainer(context: Context) {
val draftStore = MessageDraftStore(context)
val messageCache = LocalMessageCache(context)
val attachmentCache = AttachmentDiskCache(context)
val avatarCache = AvatarDiskCache(context)
val api = QMaxApi()
val repository = QMaxRepository(context, api, tokenStore, draftStore, messageCache, attachmentCache)
val repository = QMaxRepository(context, api, tokenStore, draftStore, messageCache, attachmentCache, avatarCache)
}
@@ -19,6 +19,7 @@ import okhttp3.RequestBody
import okio.BufferedSink
import xyz.kusoft.qmax.BuildConfig
import xyz.kusoft.qmax.core.local.AttachmentDiskCache
import xyz.kusoft.qmax.core.local.AvatarDiskCache
import xyz.kusoft.qmax.core.local.LocalMessageCache
import xyz.kusoft.qmax.core.model.ArgusManifestDto
import xyz.kusoft.qmax.core.model.AttachmentDto
@@ -43,7 +44,8 @@ class QMaxRepository(
private val tokenStore: TokenStore,
private val draftStore: MessageDraftStore,
private val messageCache: LocalMessageCache,
private val attachmentCache: AttachmentDiskCache
private val attachmentCache: AttachmentDiskCache,
private val avatarCache: AvatarDiskCache
) {
val session: Flow<QMaxSession?> = tokenStore.session
val drafts: Flow<Map<String, String>> = draftStore.drafts
@@ -68,6 +70,7 @@ class QMaxRepository(
draftStore.clearAll()
messageCache.clearAll()
attachmentCache.clearAll()
avatarCache.clearAll()
}
suspend fun cachedChats(session: QMaxSession): List<ChatDto> {
@@ -113,6 +116,40 @@ class QMaxRepository(
messageCache.saveChats(session, orderedChats(updatedChats))
}
suspend fun clearChatHistories(session: QMaxSession, chatIds: Collection<String>) {
val ids = chatIds.filter(String::isNotBlank).distinct()
if (ids.isEmpty()) {
return
}
withFreshSession(session) {
api.clearChatHistories(it.serverUrl, it.accessToken, ids)
}
messageCache.clearMessages(session, ids)
val updatedChats = cachedChats(session).map { chat ->
if (chat.id in ids) {
chat.copy(lastMessagePreview = null, lastMessageAt = null, unreadCount = 0)
} else {
chat
}
}
messageCache.saveChats(session, orderedChats(updatedChats))
}
suspend fun deleteChats(session: QMaxSession, chatIds: Collection<String>) {
val ids = chatIds.filter(String::isNotBlank).distinct()
if (ids.isEmpty()) {
return
}
withFreshSession(session) {
api.deleteChats(it.serverUrl, it.accessToken, ids)
}
messageCache.clearMessages(session, ids)
val updatedChats = cachedChats(session).filterNot { it.id in ids }
messageCache.saveChats(session, orderedChats(updatedChats))
}
suspend fun searchMessages(session: QMaxSession, chatId: String, query: String): List<MessageDto> {
val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) }
messages.forEach { messageCache.upsertMessage(session, it) }
@@ -222,6 +259,18 @@ class QMaxRepository(
}
}
suspend fun cachedAvatarFile(session: QMaxSession, avatarUrl: String): File {
return withFreshSession(session) { freshSession ->
val url = absoluteUrl(freshSession, avatarUrl)
val token = freshSession.accessToken.takeIf {
url.startsWith(freshSession.serverUrl.trimEnd('/'), ignoreCase = true)
}
avatarCache.cachedFile(freshSession, url) { target ->
api.downloadToFile(url, target, token)
}
}
}
suspend fun openAttachment(session: QMaxSession, attachment: AttachmentDto): File {
val file = cachedAttachmentFile(session, attachment)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
@@ -369,6 +418,31 @@ class QMaxRepository(
return refreshed
}
private suspend fun reauthenticateSession(session: QMaxSession): QMaxSession {
val serverUrl = session.serverUrl.ifBlank { BuildConfig.QMAX_DEFAULT_SERVER_URL }
val response = api.login(serverUrl, BuildConfig.QMAX_DEFAULT_PAIRING_CODE, android.os.Build.MODEL)
val recovered = QMaxSession(
serverUrl = serverUrl,
accessToken = response.accessToken,
refreshToken = response.refreshToken,
userName = response.user.displayName
)
tokenStore.save(recovered)
lastRefreshedSession = recovered
return recovered
}
private suspend fun refreshOrReauthenticateSession(session: QMaxSession): QMaxSession {
return try {
refreshSession(session)
} catch (error: QMaxHttpException) {
if (error.code != 401) {
throw error
}
reauthenticateSession(session)
}
}
fun absoluteUrl(session: QMaxSession, path: String): String = api.absoluteUrl(session.serverUrl, path)
private fun orderedChats(chats: List<ChatDto>): List<ChatDto> {
@@ -467,7 +541,7 @@ class QMaxRepository(
val refreshed = refreshMutex.withLock {
lastRefreshedSession
?.takeIf { it.serverUrl == session.serverUrl && it.accessToken != session.accessToken }
?: refreshSession(session)
?: refreshOrReauthenticateSession(session)
}
block(refreshed)
}
@@ -0,0 +1,71 @@
package xyz.kusoft.qmax.core.local
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import xyz.kusoft.qmax.core.model.QMaxSession
import java.io.File
import java.security.MessageDigest
import java.util.Locale
class AvatarDiskCache(context: Context) {
private val root = File(context.filesDir, "qmax-avatars")
suspend fun cachedFile(
session: QMaxSession,
avatarUrl: String,
downloader: suspend (File) -> Long
): File {
return withContext(Dispatchers.IO) {
val target = targetFile(session, avatarUrl)
if (isValid(target)) {
return@withContext target
}
target.delete()
downloader(target)
if (!isValid(target)) {
target.delete()
error("Avatar cache mismatch: downloaded file is empty or missing")
}
target
}
}
suspend fun clearAll() {
withContext(Dispatchers.IO) {
root.deleteRecursively()
}
}
private fun targetFile(session: QMaxSession, avatarUrl: String): File {
val sessionDir = root.resolve(cacheKey(session)).also { it.mkdirs() }
val key = sha256(avatarUrl).take(40)
return sessionDir.resolve("$key.${extension(avatarUrl)}")
}
private fun isValid(file: File): Boolean {
return file.exists() && file.length() > 0L
}
private fun cacheKey(session: QMaxSession): String {
return sha256("${session.serverUrl}|${session.userName}").take(24)
}
private fun extension(avatarUrl: String): String {
return avatarUrl
.substringBefore('?')
.substringBefore('#')
.substringAfterLast('.', "")
.lowercase(Locale.ROOT)
.takeIf { it.length in 1..12 && it.all { char -> char.isLetterOrDigit() } }
?: "img"
}
private fun sha256(value: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
}
@@ -71,6 +71,14 @@ class LocalMessageCache(context: Context) {
saveMessages(session, chatId, updated)
}
suspend fun clearMessages(session: QMaxSession, chatIds: Collection<String>) {
withContext(Dispatchers.IO) {
chatIds.forEach { chatId ->
messageFile(session, chatId).delete()
}
}
}
suspend fun clearAll() {
withContext(Dispatchers.IO) {
root.deleteRecursively()
@@ -125,6 +125,11 @@ data class CreateDirectChatRequest(
val avatarUrl: String? = null
)
@Serializable
data class ChatBulkActionRequest(
val chatIds: List<String>
)
@Serializable
data class MaxBridgeStatusDto(
val mode: String,
@@ -22,6 +22,7 @@ import xyz.kusoft.qmax.BuildConfig
import xyz.kusoft.qmax.core.model.AuthResponse
import xyz.kusoft.qmax.core.model.ArgusManifestDto
import xyz.kusoft.qmax.core.model.BeginMaxLoginRequest
import xyz.kusoft.qmax.core.model.ChatBulkActionRequest
import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ChatPresenceDto
import xyz.kusoft.qmax.core.model.CreateDirectChatRequest
@@ -86,6 +87,14 @@ class QMaxApi {
postNoContent(sessionServerUrl, "/api/chats/$chatId/read", token, MarkChatReadRequest())
}
suspend fun clearChatHistories(sessionServerUrl: String, token: String, chatIds: List<String>) {
postNoContent(sessionServerUrl, "/api/chats/clear-history", token, ChatBulkActionRequest(chatIds))
}
suspend fun deleteChats(sessionServerUrl: String, token: String, chatIds: List<String>) {
postNoContent(sessionServerUrl, "/api/chats/delete", token, ChatBulkActionRequest(chatIds))
}
suspend fun searchMessages(sessionServerUrl: String, token: String, chatId: String, query: String): List<MessageDto> {
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token)
@@ -39,6 +39,7 @@ data class QMaxUiState(
val forwardTarget: MessageDto? = null,
val drafts: Map<String, String> = emptyMap(),
val searchQuery: String = "",
val selectedChatIds: Set<String> = emptySet(),
val composerText: String = "",
val newChatExternalId: String = "",
val newChatTitle: String = "",
@@ -53,7 +54,8 @@ data class QMaxUiState(
val previewImageUrl: String? = null,
val previewImageGallery: List<String> = emptyList(),
val previewImageIndex: Int = 0,
val cachedImagePaths: Map<String, String> = emptyMap()
val cachedImagePaths: Map<String, String> = emptyMap(),
val cachedAvatarPaths: Map<String, String> = emptyMap()
)
class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
@@ -76,6 +78,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
private var pendingOpenChatId: String? = null
private val locallyReadChatFingerprints = mutableMapOf<String, String>()
private val imageCacheInFlight = mutableSetOf<String>()
private val avatarCacheInFlight = mutableSetOf<String>()
init {
viewModelScope.launch {
@@ -86,7 +89,9 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
state.value = state.value.copy(
session = session,
serverUrl = session?.serverUrl ?: state.value.serverUrl,
cachedImagePaths = if (sessionChanged) emptyMap() else state.value.cachedImagePaths
cachedImagePaths = if (sessionChanged) emptyMap() else state.value.cachedImagePaths,
cachedAvatarPaths = if (sessionChanged) emptyMap() else state.value.cachedAvatarPaths,
selectedChatIds = if (sessionChanged) emptySet() else state.value.selectedChatIds
)
if (session != null) {
autoLoginJob?.cancel()
@@ -147,6 +152,23 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
state.value = state.value.copy(searchQuery = value)
}
fun beginChatSelection(chatId: String) {
if (chatId.isBlank()) return
state.value = state.value.copy(selectedChatIds = state.value.selectedChatIds + chatId)
}
fun toggleChatSelection(chatId: String) {
if (chatId.isBlank()) return
val selected = state.value.selectedChatIds
state.value = state.value.copy(
selectedChatIds = if (chatId in selected) selected - chatId else selected + chatId
)
}
fun clearChatSelection() {
state.value = state.value.copy(selectedChatIds = emptySet())
}
fun updateNewChatExternalId(value: String) {
state.value = state.value.copy(newChatExternalId = value)
}
@@ -353,7 +375,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
val reorderedChats = if (closingChatId.isNullOrBlank()) {
state.value.chats
} else {
closingChat?.let(::rememberChatRead)
closingChat.let(::rememberChatRead)
normalizeChats(state.value.chats.map { chat ->
if (chat.id == closingChatId) chat.copy(unreadCount = 0) else chat
}, selectedChatId = closingChatId)
@@ -374,6 +396,64 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
closingChatId?.let(::syncChatRead)
}
fun clearSelectedChatHistories() = launchLoading(false) {
val session = requireSession()
val ids = state.value.selectedChatIds
if (ids.isEmpty()) return@launchLoading
repository.clearChatHistories(session, ids)
val updatedChats = normalizeChats(
state.value.chats.map { chat ->
if (chat.id in ids) {
chat.copy(lastMessagePreview = null, lastMessageAt = null, unreadCount = 0)
} else {
chat
}
}
)
val selectedChat = state.value.selectedChat
val selectedChatId = selectedChat?.id
state.value = state.value.copy(
selectedChat = selectedChat?.let { chat ->
if (chat.id in ids) chat.copy(lastMessagePreview = null, lastMessageAt = null, unreadCount = 0) else chat
},
chats = updatedChats,
messages = if (selectedChatId != null && selectedChatId in ids) emptyList() else state.value.messages,
selectedChatIds = emptySet()
)
persistCurrentChats()
refreshChats(showLoading = false)
}
fun deleteSelectedChats() = launchLoading(false) {
val session = requireSession()
val ids = state.value.selectedChatIds
if (ids.isEmpty()) return@launchLoading
repository.deleteChats(session, ids)
val selectedChatId = state.value.selectedChat?.id
val selectedChatDeleted = selectedChatId != null && selectedChatId in ids
val updatedChats = normalizeChats(state.value.chats.filterNot { it.id in ids })
state.value = state.value.copy(
selectedChat = if (selectedChatDeleted) null else state.value.selectedChat,
chats = updatedChats,
messages = if (selectedChatDeleted) emptyList() else state.value.messages,
chatPresenceText = if (selectedChatDeleted) null else state.value.chatPresenceText,
replyTarget = if (selectedChatDeleted) null else state.value.replyTarget,
editTarget = if (selectedChatDeleted) null else state.value.editTarget,
forwardTarget = if (selectedChatDeleted) null else state.value.forwardTarget,
composerText = if (selectedChatDeleted) "" else state.value.composerText,
selectedChatIds = emptySet()
)
if (selectedChatDeleted) {
messagePollJob?.cancel()
presencePollJob?.cancel()
realtime.joinChat(null)
}
persistCurrentChats()
refreshChats(showLoading = false)
}
fun loadMessages(showLoading: Boolean = true) = launchLoading(showLoading) {
if (!showLoading && state.value.sendingMessage) {
return@launchLoading
@@ -777,6 +857,30 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
}
}
fun cacheAvatar(avatarUrl: String) {
val session = state.value.session ?: return
val key = repository.absoluteUrl(session, avatarUrl)
if (state.value.cachedAvatarPaths.containsKey(key) || !avatarCacheInFlight.add(key)) {
return
}
viewModelScope.launch {
try {
val file = repository.cachedAvatarFile(session, key)
val currentSession = state.value.session
if (currentSession?.serverUrl == session.serverUrl && currentSession.userName == session.userName) {
state.value = state.value.copy(
cachedAvatarPaths = state.value.cachedAvatarPaths + (key to file.absolutePath)
)
}
} catch (_: Throwable) {
// The letter avatar remains visible if the remote avatar cannot be cached.
} finally {
avatarCacheInFlight.remove(key)
}
}
}
private fun requireSession(): QMaxSession = state.value.session ?: error("No QMAX session")
private fun saveCurrentDraft() {