Add chat bulk actions and avatar cache
This commit is contained in:
@@ -22,8 +22,8 @@ android {
|
|||||||
applicationId = "xyz.kusoft.qmax"
|
applicationId = "xyz.kusoft.qmax"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 49
|
versionCode = 51
|
||||||
versionName = "0.1.48"
|
versionName = "0.1.50"
|
||||||
|
|
||||||
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
|
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
|
||||||
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatListScreen(vm: QMaxViewModel) {
|
private fun ChatListScreen(vm: QMaxViewModel) {
|
||||||
val state by vm.state
|
val state by vm.state
|
||||||
var menuOpen by remember { mutableStateOf(false) }
|
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 searchMode by remember { mutableStateOf(false) }
|
||||||
var newChatOpen by remember { mutableStateOf(false) }
|
var newChatOpen by remember { mutableStateOf(false) }
|
||||||
val filteredChats = remember(state.chats, state.searchQuery) {
|
val filteredChats = remember(state.chats, state.searchQuery) {
|
||||||
@@ -350,9 +357,69 @@ private fun ChatListScreen(vm: QMaxViewModel) {
|
|||||||
onLogout = vm::logout,
|
onLogout = vm::logout,
|
||||||
onSearch = vm::updateSearchQuery,
|
onSearch = vm::updateSearchQuery,
|
||||||
onOpenChat = vm::openChat,
|
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) {
|
if (newChatOpen) {
|
||||||
NewChatDialog(
|
NewChatDialog(
|
||||||
vm = vm,
|
vm = vm,
|
||||||
@@ -483,7 +550,9 @@ private fun ChatListScreen(vm: QMaxViewModel) {
|
|||||||
ChatRow(
|
ChatRow(
|
||||||
chat = chat,
|
chat = chat,
|
||||||
session = state.session,
|
session = state.session,
|
||||||
|
cachedAvatarPaths = state.cachedAvatarPaths,
|
||||||
draftText = state.drafts[chat.id],
|
draftText = state.drafts[chat.id],
|
||||||
|
onCacheAvatar = vm::cacheAvatar,
|
||||||
onClick = { vm.openChat(chat) }
|
onClick = { vm.openChat(chat) }
|
||||||
)
|
)
|
||||||
HorizontalDivider(color = Color(0xFFE9EEF2), thickness = 0.6.dp, modifier = Modifier.padding(start = 74.dp))
|
HorizontalDivider(color = Color(0xFFE9EEF2), thickness = 0.6.dp, modifier = Modifier.padding(start = 74.dp))
|
||||||
@@ -516,8 +585,17 @@ private fun TelegramChatListContent(
|
|||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onSearch: (String) -> Unit,
|
onSearch: (String) -> Unit,
|
||||||
onOpenChat: (ChatDto) -> 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)) {
|
Box(Modifier.fillMaxSize().background(Color.White)) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -525,14 +603,25 @@ private fun TelegramChatListContent(
|
|||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.background(Color.White)
|
.background(Color.White)
|
||||||
) {
|
) {
|
||||||
TelegramLikeHeader(
|
if (selectedCount > 0) {
|
||||||
title = if (state.chatListConnectionIssue) "Соединение..." else "QMAX",
|
ChatSelectionHeader(
|
||||||
menuOpen = menuOpen,
|
selectedCount = selectedCount,
|
||||||
onMenuOpenChange = onMenuOpenChange,
|
menuOpen = selectionMenuOpen,
|
||||||
onRefresh = onRefresh,
|
onMenuOpenChange = onSelectionMenuOpenChange,
|
||||||
onCheckUpdates = onCheckUpdates,
|
onClearSelection = onClearSelection,
|
||||||
onLogout = onLogout
|
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)
|
ChatListSearchBar(query = state.searchQuery, onQuery = onSearch)
|
||||||
state.updateMessage?.let {
|
state.updateMessage?.let {
|
||||||
Text(
|
Text(
|
||||||
@@ -557,8 +646,19 @@ private fun TelegramChatListContent(
|
|||||||
TelegramChatRow(
|
TelegramChatRow(
|
||||||
chat = chat,
|
chat = chat,
|
||||||
session = state.session,
|
session = state.session,
|
||||||
|
cachedAvatarPaths = state.cachedAvatarPaths,
|
||||||
draftText = state.drafts[chat.id],
|
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
|
@Composable
|
||||||
private fun ChatListSearchBar(query: String, onQuery: (String) -> Unit) {
|
private fun ChatListSearchBar(query: String, onQuery: (String) -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -716,8 +891,19 @@ private fun ChatListSearchBar(query: String, onQuery: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@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 hasDraft = !draftText.isNullOrBlank()
|
||||||
val preview = if (hasDraft) {
|
val preview = if (hasDraft) {
|
||||||
"Черновик: ${draftText.trim()}"
|
"Черновик: ${draftText.trim()}"
|
||||||
@@ -726,15 +912,50 @@ private fun TelegramChatRow(chat: ChatDto, session: QMaxSession?, draftText: Str
|
|||||||
}
|
}
|
||||||
val isMediaPreview = !hasDraft && isGenericMediaPreview(chat.lastMessagePreview)
|
val isMediaPreview = !hasDraft && isGenericMediaPreview(chat.lastMessagePreview)
|
||||||
val unreadText = chat.unreadCount.coerceAtMost(999).toString()
|
val unreadText = chat.unreadCount.coerceAtMost(999).toString()
|
||||||
|
val rowBackground = when {
|
||||||
|
isSelected -> Color(0xFFEAF7FF)
|
||||||
|
selectionActive -> Color(0xFFFAFCFD)
|
||||||
|
else -> Color.Transparent
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick)
|
.background(rowBackground)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick
|
||||||
|
)
|
||||||
.padding(horizontal = 26.dp, vertical = 8.dp),
|
.padding(horizontal = 26.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
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))
|
Spacer(Modifier.width(14.dp))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -987,7 +1208,14 @@ private fun ServiceBanner(vm: QMaxViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 hasDraft = !draftText.isNullOrBlank()
|
||||||
val preview = if (hasDraft) {
|
val preview = if (hasDraft) {
|
||||||
"Черновик: ${draftText.trim()}"
|
"Черновик: ${draftText.trim()}"
|
||||||
@@ -1003,7 +1231,14 @@ private fun ChatRow(chat: ChatDto, session: QMaxSession?, draftText: String?, on
|
|||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
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))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -1340,7 +1575,14 @@ private fun ChatScreen(vm: QMaxViewModel) {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
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))
|
Spacer(Modifier.width(10.dp))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(chat.title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold)
|
Text(chat.title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold)
|
||||||
@@ -1566,6 +1808,9 @@ private fun ChatScreen(vm: QMaxViewModel) {
|
|||||||
ForwardMessageDialog(
|
ForwardMessageDialog(
|
||||||
chats = state.chats,
|
chats = state.chats,
|
||||||
selectedChatId = chat.id,
|
selectedChatId = chat.id,
|
||||||
|
session = session,
|
||||||
|
cachedAvatarPaths = state.cachedAvatarPaths,
|
||||||
|
onCacheAvatar = vm::cacheAvatar,
|
||||||
onSelect = vm::forwardTo,
|
onSelect = vm::forwardTo,
|
||||||
onClose = vm::closeForwardPicker
|
onClose = vm::closeForwardPicker
|
||||||
)
|
)
|
||||||
@@ -1576,6 +1821,9 @@ private fun ChatScreen(vm: QMaxViewModel) {
|
|||||||
private fun ForwardMessageDialog(
|
private fun ForwardMessageDialog(
|
||||||
chats: List<ChatDto>,
|
chats: List<ChatDto>,
|
||||||
selectedChatId: String?,
|
selectedChatId: String?,
|
||||||
|
session: QMaxSession?,
|
||||||
|
cachedAvatarPaths: Map<String, String>,
|
||||||
|
onCacheAvatar: (String) -> Unit,
|
||||||
onSelect: (ChatDto) -> Unit,
|
onSelect: (ChatDto) -> Unit,
|
||||||
onClose: () -> Unit
|
onClose: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -1603,6 +1851,9 @@ private fun ForwardMessageDialog(
|
|||||||
ForwardChatRow(
|
ForwardChatRow(
|
||||||
chat = chat,
|
chat = chat,
|
||||||
isCurrent = chat.id == selectedChatId,
|
isCurrent = chat.id == selectedChatId,
|
||||||
|
session = session,
|
||||||
|
cachedAvatarPaths = cachedAvatarPaths,
|
||||||
|
onCacheAvatar = onCacheAvatar,
|
||||||
onClick = { onSelect(chat) }
|
onClick = { onSelect(chat) }
|
||||||
)
|
)
|
||||||
HorizontalDivider(color = Color(0xFFE9EEF2), thickness = 0.6.dp, modifier = Modifier.padding(start = 56.dp))
|
HorizontalDivider(color = Color(0xFFE9EEF2), thickness = 0.6.dp, modifier = Modifier.padding(start = 56.dp))
|
||||||
@@ -1614,7 +1865,14 @@ private fun ForwardMessageDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -1622,7 +1880,14 @@ private fun ForwardChatRow(chat: ChatDto, isCurrent: Boolean, onClick: () -> Uni
|
|||||||
.padding(horizontal = 2.dp, vertical = 9.dp),
|
.padding(horizontal = 2.dp, vertical = 9.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
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))
|
Spacer(Modifier.width(10.dp))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -3011,7 +3276,9 @@ private fun Avatar(
|
|||||||
title: String,
|
title: String,
|
||||||
size: androidx.compose.ui.unit.Dp,
|
size: androidx.compose.ui.unit.Dp,
|
||||||
avatarUrl: String? = null,
|
avatarUrl: String? = null,
|
||||||
session: QMaxSession? = null
|
session: QMaxSession? = null,
|
||||||
|
cachedAvatarPaths: Map<String, String> = emptyMap(),
|
||||||
|
onCacheAvatar: (String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val resolvedAvatarUrl = remember(avatarUrl, session?.serverUrl) {
|
val resolvedAvatarUrl = remember(avatarUrl, session?.serverUrl) {
|
||||||
avatarUrl?.takeIf { it.isNotBlank() }?.let { url ->
|
avatarUrl?.takeIf { it.isNotBlank() }?.let { url ->
|
||||||
@@ -3024,14 +3291,13 @@ private fun Avatar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val avatarModel = if (resolvedAvatarUrl != null &&
|
val cachedAvatarPath = resolvedAvatarUrl?.let(cachedAvatarPaths::get)
|
||||||
session != null &&
|
LaunchedEffect(resolvedAvatarUrl, cachedAvatarPath) {
|
||||||
resolvedAvatarUrl.startsWith(session.serverUrl.trimEnd('/'), ignoreCase = true)
|
if (resolvedAvatarUrl != null && cachedAvatarPath == null) {
|
||||||
) {
|
onCacheAvatar(resolvedAvatarUrl)
|
||||||
imageRequest(resolvedAvatarUrl, session.accessToken)
|
}
|
||||||
} else {
|
|
||||||
resolvedAvatarUrl
|
|
||||||
}
|
}
|
||||||
|
val avatarModel = cachedAvatarPath?.let(::File)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package xyz.kusoft.qmax.core
|
package xyz.kusoft.qmax.core
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import xyz.kusoft.qmax.core.local.AvatarDiskCache
|
||||||
import xyz.kusoft.qmax.core.local.AttachmentDiskCache
|
import xyz.kusoft.qmax.core.local.AttachmentDiskCache
|
||||||
import xyz.kusoft.qmax.core.local.LocalMessageCache
|
import xyz.kusoft.qmax.core.local.LocalMessageCache
|
||||||
import xyz.kusoft.qmax.core.network.QMaxApi
|
import xyz.kusoft.qmax.core.network.QMaxApi
|
||||||
@@ -12,6 +13,7 @@ class QMaxContainer(context: Context) {
|
|||||||
val draftStore = MessageDraftStore(context)
|
val draftStore = MessageDraftStore(context)
|
||||||
val messageCache = LocalMessageCache(context)
|
val messageCache = LocalMessageCache(context)
|
||||||
val attachmentCache = AttachmentDiskCache(context)
|
val attachmentCache = AttachmentDiskCache(context)
|
||||||
|
val avatarCache = AvatarDiskCache(context)
|
||||||
val api = QMaxApi()
|
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 okio.BufferedSink
|
||||||
import xyz.kusoft.qmax.BuildConfig
|
import xyz.kusoft.qmax.BuildConfig
|
||||||
import xyz.kusoft.qmax.core.local.AttachmentDiskCache
|
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.local.LocalMessageCache
|
||||||
import xyz.kusoft.qmax.core.model.ArgusManifestDto
|
import xyz.kusoft.qmax.core.model.ArgusManifestDto
|
||||||
import xyz.kusoft.qmax.core.model.AttachmentDto
|
import xyz.kusoft.qmax.core.model.AttachmentDto
|
||||||
@@ -43,7 +44,8 @@ class QMaxRepository(
|
|||||||
private val tokenStore: TokenStore,
|
private val tokenStore: TokenStore,
|
||||||
private val draftStore: MessageDraftStore,
|
private val draftStore: MessageDraftStore,
|
||||||
private val messageCache: LocalMessageCache,
|
private val messageCache: LocalMessageCache,
|
||||||
private val attachmentCache: AttachmentDiskCache
|
private val attachmentCache: AttachmentDiskCache,
|
||||||
|
private val avatarCache: AvatarDiskCache
|
||||||
) {
|
) {
|
||||||
val session: Flow<QMaxSession?> = tokenStore.session
|
val session: Flow<QMaxSession?> = tokenStore.session
|
||||||
val drafts: Flow<Map<String, String>> = draftStore.drafts
|
val drafts: Flow<Map<String, String>> = draftStore.drafts
|
||||||
@@ -68,6 +70,7 @@ class QMaxRepository(
|
|||||||
draftStore.clearAll()
|
draftStore.clearAll()
|
||||||
messageCache.clearAll()
|
messageCache.clearAll()
|
||||||
attachmentCache.clearAll()
|
attachmentCache.clearAll()
|
||||||
|
avatarCache.clearAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun cachedChats(session: QMaxSession): List<ChatDto> {
|
suspend fun cachedChats(session: QMaxSession): List<ChatDto> {
|
||||||
@@ -113,6 +116,40 @@ class QMaxRepository(
|
|||||||
messageCache.saveChats(session, orderedChats(updatedChats))
|
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> {
|
suspend fun searchMessages(session: QMaxSession, chatId: String, query: String): List<MessageDto> {
|
||||||
val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) }
|
val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) }
|
||||||
messages.forEach { messageCache.upsertMessage(session, it) }
|
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 {
|
suspend fun openAttachment(session: QMaxSession, attachment: AttachmentDto): File {
|
||||||
val file = cachedAttachmentFile(session, attachment)
|
val file = cachedAttachmentFile(session, attachment)
|
||||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
@@ -369,6 +418,31 @@ class QMaxRepository(
|
|||||||
return refreshed
|
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)
|
fun absoluteUrl(session: QMaxSession, path: String): String = api.absoluteUrl(session.serverUrl, path)
|
||||||
|
|
||||||
private fun orderedChats(chats: List<ChatDto>): List<ChatDto> {
|
private fun orderedChats(chats: List<ChatDto>): List<ChatDto> {
|
||||||
@@ -467,7 +541,7 @@ class QMaxRepository(
|
|||||||
val refreshed = refreshMutex.withLock {
|
val refreshed = refreshMutex.withLock {
|
||||||
lastRefreshedSession
|
lastRefreshedSession
|
||||||
?.takeIf { it.serverUrl == session.serverUrl && it.accessToken != session.accessToken }
|
?.takeIf { it.serverUrl == session.serverUrl && it.accessToken != session.accessToken }
|
||||||
?: refreshSession(session)
|
?: refreshOrReauthenticateSession(session)
|
||||||
}
|
}
|
||||||
block(refreshed)
|
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)
|
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() {
|
suspend fun clearAll() {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
root.deleteRecursively()
|
root.deleteRecursively()
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ data class CreateDirectChatRequest(
|
|||||||
val avatarUrl: String? = null
|
val avatarUrl: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChatBulkActionRequest(
|
||||||
|
val chatIds: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MaxBridgeStatusDto(
|
data class MaxBridgeStatusDto(
|
||||||
val mode: String,
|
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.AuthResponse
|
||||||
import xyz.kusoft.qmax.core.model.ArgusManifestDto
|
import xyz.kusoft.qmax.core.model.ArgusManifestDto
|
||||||
import xyz.kusoft.qmax.core.model.BeginMaxLoginRequest
|
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.ChatDto
|
||||||
import xyz.kusoft.qmax.core.model.ChatPresenceDto
|
import xyz.kusoft.qmax.core.model.ChatPresenceDto
|
||||||
import xyz.kusoft.qmax.core.model.CreateDirectChatRequest
|
import xyz.kusoft.qmax.core.model.CreateDirectChatRequest
|
||||||
@@ -86,6 +87,14 @@ class QMaxApi {
|
|||||||
postNoContent(sessionServerUrl, "/api/chats/$chatId/read", token, MarkChatReadRequest())
|
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> {
|
suspend fun searchMessages(sessionServerUrl: String, token: String, chatId: String, query: String): List<MessageDto> {
|
||||||
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
|
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
|
||||||
return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token)
|
return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ data class QMaxUiState(
|
|||||||
val forwardTarget: MessageDto? = null,
|
val forwardTarget: MessageDto? = null,
|
||||||
val drafts: Map<String, String> = emptyMap(),
|
val drafts: Map<String, String> = emptyMap(),
|
||||||
val searchQuery: String = "",
|
val searchQuery: String = "",
|
||||||
|
val selectedChatIds: Set<String> = emptySet(),
|
||||||
val composerText: String = "",
|
val composerText: String = "",
|
||||||
val newChatExternalId: String = "",
|
val newChatExternalId: String = "",
|
||||||
val newChatTitle: String = "",
|
val newChatTitle: String = "",
|
||||||
@@ -53,7 +54,8 @@ data class QMaxUiState(
|
|||||||
val previewImageUrl: String? = null,
|
val previewImageUrl: String? = null,
|
||||||
val previewImageGallery: List<String> = emptyList(),
|
val previewImageGallery: List<String> = emptyList(),
|
||||||
val previewImageIndex: Int = 0,
|
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() {
|
class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
||||||
@@ -76,6 +78,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
private var pendingOpenChatId: String? = null
|
private var pendingOpenChatId: String? = null
|
||||||
private val locallyReadChatFingerprints = mutableMapOf<String, String>()
|
private val locallyReadChatFingerprints = mutableMapOf<String, String>()
|
||||||
private val imageCacheInFlight = mutableSetOf<String>()
|
private val imageCacheInFlight = mutableSetOf<String>()
|
||||||
|
private val avatarCacheInFlight = mutableSetOf<String>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -86,7 +89,9 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
state.value = state.value.copy(
|
state.value = state.value.copy(
|
||||||
session = session,
|
session = session,
|
||||||
serverUrl = session?.serverUrl ?: state.value.serverUrl,
|
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) {
|
if (session != null) {
|
||||||
autoLoginJob?.cancel()
|
autoLoginJob?.cancel()
|
||||||
@@ -147,6 +152,23 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
state.value = state.value.copy(searchQuery = value)
|
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) {
|
fun updateNewChatExternalId(value: String) {
|
||||||
state.value = state.value.copy(newChatExternalId = value)
|
state.value = state.value.copy(newChatExternalId = value)
|
||||||
}
|
}
|
||||||
@@ -353,7 +375,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
val reorderedChats = if (closingChatId.isNullOrBlank()) {
|
val reorderedChats = if (closingChatId.isNullOrBlank()) {
|
||||||
state.value.chats
|
state.value.chats
|
||||||
} else {
|
} else {
|
||||||
closingChat?.let(::rememberChatRead)
|
closingChat.let(::rememberChatRead)
|
||||||
normalizeChats(state.value.chats.map { chat ->
|
normalizeChats(state.value.chats.map { chat ->
|
||||||
if (chat.id == closingChatId) chat.copy(unreadCount = 0) else chat
|
if (chat.id == closingChatId) chat.copy(unreadCount = 0) else chat
|
||||||
}, selectedChatId = closingChatId)
|
}, selectedChatId = closingChatId)
|
||||||
@@ -374,6 +396,64 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
closingChatId?.let(::syncChatRead)
|
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) {
|
fun loadMessages(showLoading: Boolean = true) = launchLoading(showLoading) {
|
||||||
if (!showLoading && state.value.sendingMessage) {
|
if (!showLoading && state.value.sendingMessage) {
|
||||||
return@launchLoading
|
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 requireSession(): QMaxSession = state.value.session ?: error("No QMAX session")
|
||||||
|
|
||||||
private fun saveCurrentDraft() {
|
private fun saveCurrentDraft() {
|
||||||
|
|||||||
@@ -49,5 +49,6 @@ public sealed record ForwardMessageRequest(Guid TargetChatId);
|
|||||||
public sealed record SetReactionRequest(string Emoji);
|
public sealed record SetReactionRequest(string Emoji);
|
||||||
public sealed record MessageDeletedDto(Guid ChatId, Guid MessageId);
|
public sealed record MessageDeletedDto(Guid ChatId, Guid MessageId);
|
||||||
public sealed record CreateDirectChatRequest(string ExternalChatId, string Title, string? AvatarUrl = null);
|
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);
|
public sealed record ChatPresenceDto(bool IsTyping, string? StatusText, DateTimeOffset UpdatedAt);
|
||||||
|
|||||||
@@ -121,6 +121,86 @@ public sealed class ChatsController(
|
|||||||
return NoContent();
|
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")]
|
[HttpGet("{chatId:guid}/search")]
|
||||||
public async Task<ActionResult<IReadOnlyList<MessageDto>>> SearchMessages(
|
public async Task<ActionResult<IReadOnlyList<MessageDto>>> SearchMessages(
|
||||||
Guid chatId,
|
Guid chatId,
|
||||||
@@ -656,6 +736,56 @@ public sealed class ChatsController(
|
|||||||
statusCode: StatusCodes.Status409Conflict);
|
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)
|
private static string? NormalizeReaction(string? value)
|
||||||
{
|
{
|
||||||
var emoji = value?.Trim();
|
var emoji = value?.Trim();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public interface IMaxBridgeClient
|
|||||||
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
|
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
|
||||||
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
|
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
|
||||||
Task<MaxChatPresence?> FetchChatPresenceAsync(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> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken);
|
||||||
Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, 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);
|
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));
|
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)
|
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)));
|
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);
|
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)
|
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)
|
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);
|
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)
|
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
|
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);
|
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)
|
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
|
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);
|
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)
|
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX send failure."));
|
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);
|
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)
|
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
|
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);
|
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)
|
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
|
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);
|
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)
|
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
|
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) => {
|
app.post("/chat/clear-composer", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const externalChatId = String(req.body?.externalChatId || "").trim();
|
const externalChatId = String(req.body?.externalChatId || "").trim();
|
||||||
@@ -4614,6 +4650,46 @@ async function deleteMessageInMax(p, externalChatId, externalMessageId, currentT
|
|||||||
return { success: true, error: null };
|
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) {
|
async function setMessageReactionInMax(p, externalChatId, externalMessageId, currentText, emoji) {
|
||||||
const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText);
|
const target = await locateMessageForAction(p, externalChatId, externalMessageId, currentText);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -4764,6 +4840,115 @@ async function clickMessageMenuButton(p, rect) {
|
|||||||
}, rect).catch(() => false);
|
}, 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) {
|
async function hasActionSurface(p) {
|
||||||
return await p.evaluate(() => {
|
return await p.evaluate(() => {
|
||||||
const isVisible = (element) => {
|
const isVisible = (element) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user