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() {
@@ -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<Guid> ChatIds);
public sealed record ChatPresenceDto(bool IsTyping, string? StatusText, DateTimeOffset UpdatedAt);
@@ -121,6 +121,86 @@ public sealed class ChatsController(
return NoContent();
}
[HttpPost("clear-history")]
public async Task<IActionResult> 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<IActionResult> 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<ActionResult<IReadOnlyList<MessageDto>>> SearchMessages(
Guid chatId,
@@ -656,6 +736,56 @@ public sealed class ChatsController(
statusCode: StatusCodes.Status409Conflict);
}
private async Task<ActionResult?> TryApplyMaxChatActionAsync(
Chat chat,
Func<string, string?, Task<MaxActionResult>> 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<List<Chat>> LoadChatsWithAttachmentsAsync(IReadOnlyCollection<Guid> 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<string> AttachmentFiles(IEnumerable<Chat> 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<string> files)
{
foreach (var file in files)
{
System.IO.File.Delete(file);
}
}
private static string? NormalizeReaction(string? value)
{
var emoji = value?.Trim();
@@ -9,6 +9,8 @@ public interface IMaxBridgeClient
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken);
Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken);
Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken);
@@ -56,6 +56,16 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
return Task.FromResult<MaxChatPresence?>(new MaxChatPresence(false, null, DateTimeOffset.UtcNow));
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(true, $"mock-out-{Guid.NewGuid():N}", null, MockChatUrl(externalChatId)));
@@ -53,6 +53,26 @@ public sealed class WorkerMaxBridgeClient(
return await SendAsync<MaxChatPresence>(HttpMethod.Post, "/chat/presence", new { externalChatId, chatUrl }, cancellationToken);
}
public async Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return await SendAsync<MaxActionResult>(
HttpMethod.Post,
"/chat/clear-history",
new { externalChatId, chatUrl },
cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty clear history result.");
}
public async Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return await SendAsync<MaxActionResult>(
HttpMethod.Post,
"/chat/delete",
new { externalChatId, chatUrl },
cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty delete chat result.");
}
public async Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{
return await SendAsync<MaxSendResult>(HttpMethod.Post, "/send/text", new { externalChatId, chatUrl, text }, cancellationToken)
+60
View File
@@ -1282,6 +1282,16 @@ public sealed class ApiSmokeTests : IDisposable
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxSendResult> 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<MaxChatPresence?>(null);
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Fail();
}
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Fail();
}
public Task<MaxSendResult> 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<MaxChatPresence?>(null);
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure."));
}
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure."));
}
public Task<MaxSendResult> 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<MaxChatPresence?>(null);
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxSendResult> 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<MaxChatPresence?>(null);
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxSendResult> 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<MaxChatPresence?>(null);
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
+185
View File
@@ -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) => {