Add chat bulk actions and avatar cache
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user