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() {
|
||||
|
||||
Reference in New Issue
Block a user