From 4db643b02808ce810eaea5dd478bfe0fc4b84d28 Mon Sep 17 00:00:00 2001 From: sevenhill Date: Sun, 5 Jul 2026 17:49:08 +0300 Subject: [PATCH] Add chat bulk actions and avatar cache --- android/app/build.gradle.kts | 4 +- .../main/java/xyz/kusoft/qmax/MainActivity.kt | 320 ++++++++++++++++-- .../xyz/kusoft/qmax/core/QMaxContainer.kt | 4 +- .../xyz/kusoft/qmax/core/QMaxRepository.kt | 78 ++++- .../kusoft/qmax/core/local/AvatarDiskCache.kt | 71 ++++ .../qmax/core/local/LocalMessageCache.kt | 8 + .../java/xyz/kusoft/qmax/core/model/Models.kt | 5 + .../xyz/kusoft/qmax/core/network/QMaxApi.kt | 9 + .../java/xyz/kusoft/qmax/ui/QMaxViewModel.kt | 110 +++++- server/QMax.Api/Contracts/ChatContracts.cs | 1 + .../QMax.Api/Controllers/ChatsController.cs | 130 +++++++ .../Infrastructure/Max/IMaxBridgeClient.cs | 2 + .../Infrastructure/Max/MockMaxBridgeClient.cs | 10 + .../Max/WorkerMaxBridgeClient.cs | 20 ++ tests/QMax.Tests/ApiSmokeTests.cs | 60 ++++ worker/src/server.js | 185 ++++++++++ 16 files changed, 982 insertions(+), 35 deletions(-) create mode 100644 android/app/src/main/java/xyz/kusoft/qmax/core/local/AvatarDiskCache.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4641fe4..9eb8982 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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\"") diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt index e391995..74cba42 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -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(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, + 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, + 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, selectedChatId: String?, + session: QMaxSession?, + cachedAvatarPaths: Map, + 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, + 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 = 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 diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxContainer.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxContainer.kt index 2f5c514..ddd4a0c 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxContainer.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxContainer.kt @@ -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) } diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt index 9bb04ee..9f09bd9 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt @@ -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 = tokenStore.session val drafts: Flow> = draftStore.drafts @@ -68,6 +70,7 @@ class QMaxRepository( draftStore.clearAll() messageCache.clearAll() attachmentCache.clearAll() + avatarCache.clearAll() } suspend fun cachedChats(session: QMaxSession): List { @@ -113,6 +116,40 @@ class QMaxRepository( messageCache.saveChats(session, orderedChats(updatedChats)) } + suspend fun clearChatHistories(session: QMaxSession, chatIds: Collection) { + 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) { + 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 { 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): List { @@ -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) } diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/local/AvatarDiskCache.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/local/AvatarDiskCache.kt new file mode 100644 index 0000000..52f882d --- /dev/null +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/local/AvatarDiskCache.kt @@ -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) } + } +} diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/local/LocalMessageCache.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/local/LocalMessageCache.kt index c68dea3..a422f40 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/local/LocalMessageCache.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/local/LocalMessageCache.kt @@ -71,6 +71,14 @@ class LocalMessageCache(context: Context) { saveMessages(session, chatId, updated) } + suspend fun clearMessages(session: QMaxSession, chatIds: Collection) { + withContext(Dispatchers.IO) { + chatIds.forEach { chatId -> + messageFile(session, chatId).delete() + } + } + } + suspend fun clearAll() { withContext(Dispatchers.IO) { root.deleteRecursively() diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt index 9e2267e..7e9ee3e 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt @@ -125,6 +125,11 @@ data class CreateDirectChatRequest( val avatarUrl: String? = null ) +@Serializable +data class ChatBulkActionRequest( + val chatIds: List +) + @Serializable data class MaxBridgeStatusDto( val mode: String, diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt index 60d7963..ca97758 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt @@ -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) { + postNoContent(sessionServerUrl, "/api/chats/clear-history", token, ChatBulkActionRequest(chatIds)) + } + + suspend fun deleteChats(sessionServerUrl: String, token: String, chatIds: List) { + postNoContent(sessionServerUrl, "/api/chats/delete", token, ChatBulkActionRequest(chatIds)) + } + suspend fun searchMessages(sessionServerUrl: String, token: String, chatId: String, query: String): List { val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString()) return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token) diff --git a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt index 97be422..2c31d0c 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt @@ -39,6 +39,7 @@ data class QMaxUiState( val forwardTarget: MessageDto? = null, val drafts: Map = emptyMap(), val searchQuery: String = "", + val selectedChatIds: Set = emptySet(), val composerText: String = "", val newChatExternalId: String = "", val newChatTitle: String = "", @@ -53,7 +54,8 @@ data class QMaxUiState( val previewImageUrl: String? = null, val previewImageGallery: List = emptyList(), val previewImageIndex: Int = 0, - val cachedImagePaths: Map = emptyMap() + val cachedImagePaths: Map = emptyMap(), + val cachedAvatarPaths: Map = 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() private val imageCacheInFlight = mutableSetOf() + private val avatarCacheInFlight = mutableSetOf() 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() { diff --git a/server/QMax.Api/Contracts/ChatContracts.cs b/server/QMax.Api/Contracts/ChatContracts.cs index c6c7082..6edb81d 100644 --- a/server/QMax.Api/Contracts/ChatContracts.cs +++ b/server/QMax.Api/Contracts/ChatContracts.cs @@ -49,5 +49,6 @@ public sealed record ForwardMessageRequest(Guid TargetChatId); public sealed record SetReactionRequest(string Emoji); public sealed record MessageDeletedDto(Guid ChatId, Guid MessageId); public sealed record CreateDirectChatRequest(string ExternalChatId, string Title, string? AvatarUrl = null); +public sealed record ChatBulkActionRequest(IReadOnlyList ChatIds); public sealed record ChatPresenceDto(bool IsTyping, string? StatusText, DateTimeOffset UpdatedAt); diff --git a/server/QMax.Api/Controllers/ChatsController.cs b/server/QMax.Api/Controllers/ChatsController.cs index 15bf107..baeb345 100644 --- a/server/QMax.Api/Controllers/ChatsController.cs +++ b/server/QMax.Api/Controllers/ChatsController.cs @@ -121,6 +121,86 @@ public sealed class ChatsController( return NoContent(); } + [HttpPost("clear-history")] + public async Task ClearHistories(ChatBulkActionRequest request, CancellationToken cancellationToken) + { + var chatIds = request.ChatIds.Distinct().ToArray(); + if (chatIds.Length == 0) + { + return BadRequest("chatIds are required."); + } + + var chats = await LoadChatsWithAttachmentsAsync(chatIds, cancellationToken); + if (chats.Count != chatIds.Length) + { + return NotFound(); + } + + foreach (var chat in chats) + { + var maxFailure = await TryApplyMaxChatActionAsync( + chat, + (externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken), + "clear history", + cancellationToken); + if (maxFailure is not null) + { + return maxFailure; + } + } + + var files = AttachmentFiles(chats).ToArray(); + foreach (var chat in chats) + { + db.Messages.RemoveRange(chat.Messages); + chat.LastMessageAt = null; + chat.LastMessagePreview = null; + chat.UnreadCount = 0; + chat.UpdatedAt = DateTimeOffset.UtcNow; + } + + await db.SaveChangesAsync(cancellationToken); + DeleteFiles(files); + await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); + return NoContent(); + } + + [HttpPost("delete")] + public async Task DeleteChats(ChatBulkActionRequest request, CancellationToken cancellationToken) + { + var chatIds = request.ChatIds.Distinct().ToArray(); + if (chatIds.Length == 0) + { + return BadRequest("chatIds are required."); + } + + var chats = await LoadChatsWithAttachmentsAsync(chatIds, cancellationToken); + if (chats.Count != chatIds.Length) + { + return NotFound(); + } + + foreach (var chat in chats) + { + var maxFailure = await TryApplyMaxChatActionAsync( + chat, + (externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken), + "delete chat", + cancellationToken); + if (maxFailure is not null) + { + return maxFailure; + } + } + + var files = AttachmentFiles(chats).ToArray(); + db.Chats.RemoveRange(chats); + await db.SaveChangesAsync(cancellationToken); + DeleteFiles(files); + await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); + return NoContent(); + } + [HttpGet("{chatId:guid}/search")] public async Task>> SearchMessages( Guid chatId, @@ -656,6 +736,56 @@ public sealed class ChatsController( statusCode: StatusCodes.Status409Conflict); } + private async Task TryApplyMaxChatActionAsync( + Chat chat, + Func> action, + string actionName, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(chat.ExternalId)) + { + return null; + } + + var result = await action(chat.ExternalId, chat.WebUrl); + if (result.Success) + { + return null; + } + + return Problem( + result.Error ?? $"MAX {actionName} action failed for {chat.Title}.", + statusCode: StatusCodes.Status409Conflict); + } + + private async Task> LoadChatsWithAttachmentsAsync(IReadOnlyCollection chatIds, CancellationToken cancellationToken) + { + return await db.Chats + .Include(x => x.Messages) + .ThenInclude(x => x.Attachments) + .Where(x => chatIds.Contains(x.Id)) + .ToListAsync(cancellationToken); + } + + private IEnumerable AttachmentFiles(IEnumerable chats) + { + return chats + .SelectMany(x => x.Messages) + .SelectMany(x => x.Attachments) + .Select(x => storage.GetPath(x.StorageFileName)) + .Where(System.IO.File.Exists); + } + + private static void DeleteFiles(IEnumerable files) + { + foreach (var file in files) + { + System.IO.File.Delete(file); + } + } + private static string? NormalizeReaction(string? value) { var emoji = value?.Trim(); diff --git a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs index b549b75..982997c 100644 --- a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs @@ -9,6 +9,8 @@ public interface IMaxBridgeClient Task> FetchUpdatesAsync(CancellationToken cancellationToken); Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken); Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken); + Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken); + Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken); Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken); Task SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken); Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken); diff --git a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs index 5cd2ec8..c17d509 100644 --- a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs @@ -56,6 +56,16 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient return Task.FromResult(new MaxChatPresence(false, null, DateTimeOffset.UtcNow)); } + public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, $"mock-out-{Guid.NewGuid():N}", null, MockChatUrl(externalChatId))); diff --git a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs index 955a264..d852675 100644 --- a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs @@ -53,6 +53,26 @@ public sealed class WorkerMaxBridgeClient( return await SendAsync(HttpMethod.Post, "/chat/presence", new { externalChatId, chatUrl }, cancellationToken); } + public async Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return await SendAsync( + HttpMethod.Post, + "/chat/clear-history", + new { externalChatId, chatUrl }, + cancellationToken) + ?? new MaxActionResult(false, "Worker returned an empty clear history result."); + } + + public async Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return await SendAsync( + HttpMethod.Post, + "/chat/delete", + new { externalChatId, chatUrl }, + cancellationToken) + ?? new MaxActionResult(false, "Worker returned an empty delete chat result."); + } + public async Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return await SendAsync(HttpMethod.Post, "/send/text", new { externalChatId, chatUrl, text }, cancellationToken) diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index b5d7917..091f41f 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -1282,6 +1282,16 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult(null); } + public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); @@ -1355,6 +1365,16 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult(null); } + public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Fail(); + } + + public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Fail(); + } + public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); @@ -1433,6 +1453,16 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult(null); } + public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure.")); + } + + public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure.")); + } + public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX send failure.")); @@ -1512,6 +1542,16 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult(null); } + public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); @@ -1655,6 +1695,16 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult(null); } + public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); @@ -1760,6 +1810,16 @@ public sealed class ApiSmokeTests : IDisposable return Task.FromResult(null); } + public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + + public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + { + return Task.FromResult(new MaxActionResult(true, null)); + } + public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); diff --git a/worker/src/server.js b/worker/src/server.js index 11c03d0..5d5a424 100644 --- a/worker/src/server.js +++ b/worker/src/server.js @@ -338,6 +338,42 @@ app.post("/chat/history", async (req, res) => { } }); +app.post("/chat/clear-history", async (req, res) => { + try { + const externalChatId = String(req.body?.externalChatId || "").trim(); + const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); + if (!externalChatId) { + return res.json({ success: false, error: "externalChatId is required." }); + } + + const result = await withPageLock(async () => { + const p = await ensurePage("maintenance"); + return await clearChatHistoryInMax(p, externalChatId, chatUrl); + }, "maintenance"); + res.json(result); + } catch (error) { + res.json({ success: false, error: String(error?.message || error) }); + } +}); + +app.post("/chat/delete", async (req, res) => { + try { + const externalChatId = String(req.body?.externalChatId || "").trim(); + const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); + if (!externalChatId) { + return res.json({ success: false, error: "externalChatId is required." }); + } + + const result = await withPageLock(async () => { + const p = await ensurePage("maintenance"); + return await deleteChatInMax(p, externalChatId, chatUrl); + }, "maintenance"); + res.json(result); + } catch (error) { + res.json({ success: false, error: String(error?.message || error) }); + } +}); + app.post("/chat/clear-composer", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); @@ -4614,6 +4650,46 @@ async function deleteMessageInMax(p, externalChatId, externalMessageId, currentT return { success: true, error: null }; } +async function clearChatHistoryInMax(p, externalChatId, chatUrl) { + return await applyChatMenuActionInMax( + p, + externalChatId, + chatUrl, + ["\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u0438\u0441\u0442\u043e\u0440", "\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c \u0447\u0430\u0442", "clear history", "clear chat"], + ["\u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c", "\u0434\u0430", "clear", "yes", "ok"], + "clear history"); +} + +async function deleteChatInMax(p, externalChatId, chatUrl) { + return await applyChatMenuActionInMax( + p, + externalChatId, + chatUrl, + ["\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442", "\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "delete chat", "delete conversation", "remove chat"], + ["\u0443\u0434\u0430\u043b\u0438\u0442\u044c", "\u0434\u0430", "delete", "yes", "ok"], + "delete chat"); +} + +async function applyChatMenuActionInMax(p, externalChatId, chatUrl, actionLabels, confirmLabels, actionName) { + const opened = await ensureDomChatOpen(p, externalChatId, 5000, false, chatUrl); + if (!opened) { + return { success: false, error: "MAX chat was not found or did not open." }; + } + + if (!(await openChatMenu(p, actionLabels))) { + return { success: false, error: `MAX ${actionName} menu was not opened.` }; + } + + if (!(await clickActionByText(p, actionLabels))) { + return { success: false, error: `MAX ${actionName} action was not found in the chat menu.` }; + } + + await p.waitForTimeout(700); + await clickActionByText(p, confirmLabels); + await p.waitForTimeout(1200); + return { success: true, error: null }; +} + async function setMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji) { const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText); if (!target) { @@ -4764,6 +4840,115 @@ async function clickMessageMenuButton(p, rect) { }, rect).catch(() => false); } +async function openChatMenu(p, expectedLabels) { + for (let attempt = 0; attempt < 3; attempt++) { + if (await clickChatMenuButton(p)) { + await p.waitForTimeout(600); + if (await hasMenuActionSurface(p, expectedLabels)) { + return true; + } + } + + await p.keyboard.press("Escape").catch(() => {}); + await p.waitForTimeout(250); + } + + return false; +} + +async function clickChatMenuButton(p) { + return await p.evaluate(() => { + const isVisible = (element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && + style.display !== "none" && + rect.width > 0 && + rect.height > 0 && + rect.bottom >= 0 && + rect.right >= 0 && + rect.top <= window.innerHeight && + rect.left <= window.innerWidth; + }; + const textOf = (element) => String( + element.innerText || + element.textContent || + element.getAttribute("aria-label") || + element.getAttribute("title") || + "" + ).trim().toLowerCase(); + const chatRoot = document.querySelector("[class*='openedChat' i]") || + document.querySelector("main") || + document.body; + const rootRect = chatRoot.getBoundingClientRect(); + const headerBottom = Math.min(window.innerHeight, rootRect.top + 170); + const buttons = Array.from(document.querySelectorAll("button,[role='button']")) + .filter((button) => { + if (!(button instanceof HTMLElement) || !isVisible(button)) return false; + const rect = button.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + return centerX >= rootRect.left + rootRect.width * 0.45 && + centerX <= rootRect.right + 8 && + centerY >= Math.max(0, rootRect.top) && + centerY <= headerBottom; + }); + const match = buttons.find((button) => { + const text = textOf(button); + const cls = String(button.className || "").toLowerCase(); + return text.includes("\u0435\u0449") || + text.includes("more") || + text.includes("menu") || + text.includes("actions") || + cls.includes("more") || + cls.includes("menu") || + text === "\u22ef" || + text === "\u2026"; + }) || buttons.sort((a, b) => b.getBoundingClientRect().right - a.getBoundingClientRect().right)[0]; + + if (!match) { + return false; + } + + match.click(); + return true; + }).catch(() => false); +} + +async function hasMenuActionSurface(p, labels) { + return await p.evaluate((rawLabels) => { + const normalizedLabels = rawLabels.map((label) => String(label || "").trim().toLowerCase()).filter(Boolean); + if (normalizedLabels.length === 0) { + return false; + } + const isVisible = (element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && + style.display !== "none" && + rect.width > 0 && + rect.height > 0 && + rect.bottom >= 0 && + rect.right >= 0 && + rect.top <= window.innerHeight && + rect.left <= window.innerWidth; + }; + const textOf = (element) => String( + element.innerText || + element.textContent || + element.getAttribute("aria-label") || + element.getAttribute("title") || + "" + ).trim().toLowerCase(); + return Array.from(document.querySelectorAll("[role='menu'],[role='menuitem'],[class*='menu' i],[class*='popover' i],[class*='dropdown' i],button,span,div")) + .filter((element) => element instanceof HTMLElement && isVisible(element)) + .some((element) => { + const text = textOf(element); + return text && normalizedLabels.some((label) => text === label || text.includes(label)); + }); + }, labels).catch(() => false); +} + async function hasActionSurface(p) { return await p.evaluate(() => { const isVisible = (element) => {