diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 4f5bd83..b21c949 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -22,8 +22,8 @@ android {
applicationId = "xyz.kusoft.qmax"
minSdk = 26
targetSdk = 36
- versionCode = 52
- versionName = "0.1.51"
+ versionCode = 54
+ versionName = "0.1.53"
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index a85f5da..d723d01 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -2,6 +2,7 @@
+
diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt
index 30863db..1715aef 100644
--- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt
+++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt
@@ -2,6 +2,7 @@ package xyz.kusoft.qmax
import android.Manifest
import android.content.Intent
+import android.content.ContentResolver
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.media.MediaRecorder
@@ -9,6 +10,7 @@ import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns
+import android.provider.ContactsContract
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.BackHandler
@@ -78,6 +80,7 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Search
@@ -169,6 +172,7 @@ import xyz.kusoft.qmax.core.model.ContactDto
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
import xyz.kusoft.qmax.core.model.MessageDto
+import xyz.kusoft.qmax.core.model.PhoneContactDto
import xyz.kusoft.qmax.core.model.QMaxSession
import xyz.kusoft.qmax.ui.QMaxUiState
import xyz.kusoft.qmax.ui.QMaxViewModel
@@ -217,6 +221,39 @@ class MainActivity : ComponentActivity() {
private fun Intent?.chatIdExtra(): String? = this?.getStringExtra("chatId")?.takeIf { it.isNotBlank() }
+private fun readPhoneContacts(contentResolver: ContentResolver): List {
+ val projection = arrayOf(
+ ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
+ ContactsContract.CommonDataKinds.Phone.NUMBER,
+ ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER
+ )
+ val seenNumbers = mutableSetOf()
+ val result = mutableListOf()
+ contentResolver.query(
+ ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
+ projection,
+ null,
+ null,
+ "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} COLLATE LOCALIZED ASC"
+ )?.use { cursor ->
+ val nameIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
+ val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
+ val normalizedIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NORMALIZED_NUMBER)
+ while (cursor.moveToNext()) {
+ val rawNumber = (cursor.getString(normalizedIndex)?.takeIf { it.isNotBlank() }
+ ?: cursor.getString(numberIndex)).orEmpty()
+ val normalizedNumber = rawNumber.filter { it.isDigit() || it == '+' }
+ if (normalizedNumber.isBlank() || !seenNumbers.add(normalizedNumber)) continue
+ val displayName = cursor.getString(nameIndex)?.trim().orEmpty().ifBlank { normalizedNumber }
+ result += PhoneContactDto(
+ phoneNumber = normalizedNumber,
+ firstName = displayName
+ )
+ }
+ }
+ return result
+}
+
@Composable
private fun QMaxApp(vm: QMaxViewModel) {
val state by vm.state
@@ -344,7 +381,8 @@ private fun LoginScreen(vm: QMaxViewModel) {
}
private enum class ChatBulkUiAction {
- ClearHistory
+ ClearHistory,
+ DeleteChats
}
private enum class MainTab {
@@ -362,6 +400,7 @@ private fun ChatListScreen(vm: QMaxViewModel) {
var menuOpen by remember { mutableStateOf(false) }
var selectionMenuOpen by remember { mutableStateOf(false) }
var pendingBulkAction by remember { mutableStateOf(null) }
+ var pendingContactRemoval by remember { mutableStateOf(null) }
var searchMode by remember { mutableStateOf(false) }
var newChatOpen by remember { mutableStateOf(false) }
val filteredChats = remember(state.chats, state.searchQuery, activeTab) {
@@ -392,6 +431,29 @@ private fun ChatListScreen(vm: QMaxViewModel) {
}
}
}
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val importPhoneContacts: () -> Unit = {
+ scope.launch {
+ val contacts = withContext(Dispatchers.IO) { readPhoneContacts(context.contentResolver) }
+ vm.importPhoneContacts(contacts)
+ }
+ Unit
+ }
+ val contactsPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (granted) {
+ importPhoneContacts()
+ } else {
+ vm.reportContactsPermissionDenied()
+ }
+ }
+ val requestPhoneContactsImport: () -> Unit = {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) {
+ importPhoneContacts()
+ } else {
+ contactsPermissionLauncher.launch(Manifest.permission.READ_CONTACTS)
+ }
+ }
LaunchedEffect(activeTab, state.searchQuery) {
if (activeTab == MainTab.Channels) {
@@ -401,6 +463,12 @@ private fun ChatListScreen(vm: QMaxViewModel) {
}
}
+ LaunchedEffect(activeTab, state.session?.userName) {
+ if (activeTab == MainTab.Contacts && state.session != null) {
+ requestPhoneContactsImport()
+ }
+ }
+
TelegramChatListContent(
state = state,
filteredChats = filteredChats,
@@ -410,7 +478,7 @@ private fun ChatListScreen(vm: QMaxViewModel) {
onMenuOpenChange = { menuOpen = it },
onRefresh = {
vm.loadChats()
- vm.loadContacts()
+ if (activeTab == MainTab.Contacts) requestPhoneContactsImport()
},
onCheckUpdates = vm::checkArgusUpdate,
onRefreshMaxStatus = vm::loadMaxStatus,
@@ -427,6 +495,9 @@ private fun ChatListScreen(vm: QMaxViewModel) {
},
onOpenChat = vm::openChat,
onOpenContact = vm::openContact,
+ onImportPhoneContacts = requestPhoneContactsImport,
+ onAddContact = vm::addContact,
+ onRemoveContact = { pendingContactRemoval = it },
onNewChat = {
activeTab = MainTab.Contacts
vm.updateSearchQuery("")
@@ -440,6 +511,10 @@ private fun ChatListScreen(vm: QMaxViewModel) {
onClearSelectedHistory = {
selectionMenuOpen = false
pendingBulkAction = ChatBulkUiAction.ClearHistory
+ },
+ onDeleteSelectedChats = {
+ selectionMenuOpen = false
+ pendingBulkAction = ChatBulkUiAction.DeleteChats
}
)
@@ -451,6 +526,7 @@ private fun ChatListScreen(vm: QMaxViewModel) {
Text(
when (action) {
ChatBulkUiAction.ClearHistory -> "Очистить историю"
+ ChatBulkUiAction.DeleteChats -> "Удалить чаты"
}
)
},
@@ -458,6 +534,7 @@ private fun ChatListScreen(vm: QMaxViewModel) {
Text(
when (action) {
ChatBulkUiAction.ClearHistory -> "Очистить историю в выбранных чатах: $selectedCount?"
+ ChatBulkUiAction.DeleteChats -> "Удалить выбранные чаты из MAX: $selectedCount? Это действие нельзя отменить."
}
)
},
@@ -467,12 +544,14 @@ private fun ChatListScreen(vm: QMaxViewModel) {
pendingBulkAction = null
when (action) {
ChatBulkUiAction.ClearHistory -> vm.clearSelectedChatHistories()
+ ChatBulkUiAction.DeleteChats -> vm.deleteSelectedChats()
}
}
) {
Text(
when (action) {
- ChatBulkUiAction.ClearHistory -> "Очистить"
+ ChatBulkUiAction.ClearHistory -> "Очистить"
+ ChatBulkUiAction.DeleteChats -> "Удалить"
}
)
}
@@ -485,6 +564,23 @@ private fun ChatListScreen(vm: QMaxViewModel) {
)
}
+ pendingContactRemoval?.let { contact ->
+ AlertDialog(
+ onDismissRequest = { pendingContactRemoval = null },
+ title = { Text("Удалить контакт") },
+ text = { Text("Удалить ${contact.displayName} из контактов MAX? Чат и сообщения останутся.") },
+ confirmButton = {
+ TextButton(onClick = {
+ pendingContactRemoval = null
+ vm.removeContact(contact)
+ }) { Text("Удалить") }
+ },
+ dismissButton = {
+ TextButton(onClick = { pendingContactRemoval = null }) { Text("Отмена") }
+ }
+ )
+ }
+
if (newChatOpen) {
NewChatDialog(
vm = vm,
@@ -659,6 +755,9 @@ private fun TelegramChatListContent(
onTabSelected: (MainTab) -> Unit,
onOpenChat: (ChatDto) -> Unit,
onOpenContact: (ContactDto) -> Unit,
+ onImportPhoneContacts: () -> Unit,
+ onAddContact: (ContactDto) -> Unit,
+ onRemoveContact: (ContactDto) -> Unit,
onNewChat: () -> Unit,
onCacheAvatar: (String) -> Unit,
selectionMenuOpen: Boolean,
@@ -666,7 +765,8 @@ private fun TelegramChatListContent(
onClearSelection: () -> Unit,
onToggleChatSelection: (String) -> Unit,
onBeginChatSelection: (String) -> Unit,
- onClearSelectedHistory: () -> Unit
+ onClearSelectedHistory: () -> Unit,
+ onDeleteSelectedChats: () -> Unit
) {
val selectedCount = state.selectedChatIds.size
val listTab = activeTab != MainTab.Settings
@@ -698,7 +798,8 @@ private fun TelegramChatListContent(
menuOpen = selectionMenuOpen,
onMenuOpenChange = onSelectionMenuOpenChange,
onClearSelection = onClearSelection,
- onClearHistory = onClearSelectedHistory
+ onClearHistory = onClearSelectedHistory,
+ onDeleteChats = onDeleteSelectedChats
)
} else {
TelegramLikeHeader(
@@ -762,6 +863,8 @@ private fun TelegramChatListContent(
session = state.session,
cachedAvatarPaths = state.cachedAvatarPaths,
onCacheAvatar = onCacheAvatar,
+ onAdd = { onAddContact(contact) },
+ onRemove = { onRemoveContact(contact) },
onClick = { onOpenContact(contact) }
)
}
@@ -798,6 +901,19 @@ private fun TelegramChatListContent(
.padding(end = 22.dp, bottom = 96.dp),
onNewChat = onNewChat
)
+ } else if (activeTab == MainTab.Contacts) {
+ FloatingActionButton(
+ onClick = onImportPhoneContacts,
+ containerColor = QMaxBlue,
+ contentColor = Color.White,
+ shape = CircleShape,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .navigationBarsPadding()
+ .padding(end = 22.dp, bottom = 96.dp)
+ ) {
+ Icon(Icons.Filled.Refresh, contentDescription = "Синхронизировать контакты телефона")
+ }
}
TelegramBottomNavigation(
modifier = Modifier
@@ -902,7 +1018,8 @@ private fun ChatSelectionHeader(
menuOpen: Boolean,
onMenuOpenChange: (Boolean) -> Unit,
onClearSelection: () -> Unit,
- onClearHistory: () -> Unit
+ onClearHistory: () -> Unit,
+ onDeleteChats: () -> Unit
) {
Row(
modifier = Modifier
@@ -951,6 +1068,13 @@ private fun ChatSelectionHeader(
onClearHistory()
}
)
+ DropdownMenuItem(
+ text = { Text("Удалить чат") },
+ onClick = {
+ onMenuOpenChange(false)
+ onDeleteChats()
+ }
+ )
}
}
}
@@ -1004,6 +1128,8 @@ private fun ContactRow(
session: QMaxSession?,
cachedAvatarPaths: Map,
onCacheAvatar: (String) -> Unit,
+ onAdd: () -> Unit,
+ onRemove: () -> Unit,
onClick: () -> Unit
) {
val subtitle = contact.status?.takeIf { it.isNotBlank() }
@@ -1043,6 +1169,13 @@ private fun ContactRow(
overflow = TextOverflow.Ellipsis
)
}
+ IconButton(onClick = if (contact.isSavedContact) onRemove else onAdd) {
+ Icon(
+ if (contact.isSavedContact) Icons.Filled.Delete else Icons.Filled.PersonAdd,
+ contentDescription = if (contact.isSavedContact) "Удалить из MAX" else "Добавить в MAX",
+ tint = if (contact.isSavedContact) QMaxMuted else QMaxBlue
+ )
+ }
Icon(Icons.Filled.Chat, contentDescription = "Открыть чат", tint = QMaxBlue)
}
}
diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt
index e7ef448..4181331 100644
--- a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt
+++ b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt
@@ -27,6 +27,7 @@ import xyz.kusoft.qmax.core.model.AuthResponse
import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ChatPresenceDto
import xyz.kusoft.qmax.core.model.ContactDto
+import xyz.kusoft.qmax.core.model.PhoneContactDto
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
import xyz.kusoft.qmax.core.model.MessageDto
@@ -96,6 +97,18 @@ class QMaxRepository(
return withFreshSession(session) { api.contacts(it.serverUrl, it.accessToken) }
}
+ suspend fun importContacts(session: QMaxSession, contacts: List): List {
+ return withFreshSession(session) { api.importContacts(it.serverUrl, it.accessToken, contacts) }
+ }
+
+ suspend fun addContact(session: QMaxSession, userId: String): ContactDto {
+ return withFreshSession(session) { api.addContact(it.serverUrl, it.accessToken, userId) }
+ }
+
+ suspend fun removeContact(session: QMaxSession, userId: String) {
+ withFreshSession(session) { api.removeContact(it.serverUrl, it.accessToken, userId) }
+ }
+
suspend fun createChat(session: QMaxSession, externalId: String, title: String, avatarUrl: String? = null): ChatDto {
val chat = withFreshSession(session) {
api.createDirect(it.serverUrl, it.accessToken, externalId, title, avatarUrl)
@@ -144,6 +157,15 @@ class QMaxRepository(
}
}
+ suspend fun deleteChats(session: QMaxSession, chatIds: Collection) {
+ val ids = chatIds.filter(String::isNotBlank).distinct()
+ if (ids.isEmpty()) return
+ withFreshSession(session) { api.deleteChats(it.serverUrl, it.accessToken, ids) }
+ val remainingChats = cachedChats(session).filterNot { it.id in ids }
+ messageCache.saveChats(session, orderedChats(remainingChats))
+ messageCache.clearMessages(session, ids)
+ }
+
suspend fun searchMessages(session: QMaxSession, chatId: String, query: String): List {
val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) }
messages.forEach { messageCache.upsertMessage(session, it) }
diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt
index 060a955..5181f1c 100644
--- a/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt
+++ b/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt
@@ -56,9 +56,23 @@ data class ContactDto(
val displayName: String,
val avatarUrl: String? = null,
val phoneNumber: String? = null,
- val status: String? = null
+ val status: String? = null,
+ val isSavedContact: Boolean = false
)
+@Serializable
+data class PhoneContactDto(
+ val phoneNumber: String,
+ val firstName: String,
+ val lastName: String? = null
+)
+
+@Serializable
+data class ImportPhoneContactsRequest(val contacts: List)
+
+@Serializable
+data class ContactUserRequest(val userId: String)
+
@Serializable
data class MessageDto(
val id: String,
diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt
index a79ba1d..6f787e5 100644
--- a/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt
+++ b/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt
@@ -27,13 +27,16 @@ import xyz.kusoft.qmax.core.model.ChatDto
import xyz.kusoft.qmax.core.model.ChatPresenceDto
import xyz.kusoft.qmax.core.model.CreateDirectChatRequest
import xyz.kusoft.qmax.core.model.ContactDto
+import xyz.kusoft.qmax.core.model.ContactUserRequest
import xyz.kusoft.qmax.core.model.DeviceLoginRequest
import xyz.kusoft.qmax.core.model.EditMessageRequest
import xyz.kusoft.qmax.core.model.ForwardMessageRequest
import xyz.kusoft.qmax.core.model.MarkChatReadRequest
+import xyz.kusoft.qmax.core.model.ImportPhoneContactsRequest
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
import xyz.kusoft.qmax.core.model.MessageDto
+import xyz.kusoft.qmax.core.model.PhoneContactDto
import xyz.kusoft.qmax.core.model.RegisterPushDeviceRequest
import xyz.kusoft.qmax.core.model.SendMessageRequest
import xyz.kusoft.qmax.core.model.SetReactionRequest
@@ -82,6 +85,18 @@ class QMaxApi {
return get(sessionServerUrl, "/api/contacts", token)
}
+ suspend fun importContacts(sessionServerUrl: String, token: String, contacts: List): List {
+ return post(sessionServerUrl, "/api/contacts/import", token, ImportPhoneContactsRequest(contacts))
+ }
+
+ suspend fun addContact(sessionServerUrl: String, token: String, userId: String): ContactDto {
+ return post(sessionServerUrl, "/api/contacts/add", token, ContactUserRequest(userId))
+ }
+
+ suspend fun removeContact(sessionServerUrl: String, token: String, userId: String) {
+ postNoContent(sessionServerUrl, "/api/contacts/remove", token, ContactUserRequest(userId))
+ }
+
suspend fun createDirect(
sessionServerUrl: String,
token: String,
@@ -104,6 +119,10 @@ class QMaxApi {
postNoContent(sessionServerUrl, "/api/chats/clear-history", token, ChatBulkActionRequest(chatIds))
}
+ suspend fun deleteChats(sessionServerUrl: String, token: String, chatIds: List) {
+ postNoContent(sessionServerUrl, "/api/chats/delete", token, ChatBulkActionRequest(chatIds))
+ }
+
suspend fun searchMessages(sessionServerUrl: String, token: String, chatId: String, query: String): List {
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token)
diff --git a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt
index f231c62..683abb6 100644
--- a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt
+++ b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt
@@ -21,6 +21,7 @@ import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
import xyz.kusoft.qmax.core.model.MessageDeletedDto
import xyz.kusoft.qmax.core.model.MessageDto
+import xyz.kusoft.qmax.core.model.PhoneContactDto
import xyz.kusoft.qmax.core.model.QMaxSession
import xyz.kusoft.qmax.core.network.QMaxHttpException
import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient
@@ -420,6 +421,64 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
loadChats()
}
+ fun reportContactsPermissionDenied() {
+ state.value = state.value.copy(
+ contactsLoading = false,
+ contactsError = "Разрешите QMAX доступ к контактам телефона и обновите список"
+ )
+ }
+
+ fun importPhoneContacts(contacts: List) = viewModelScope.launch {
+ state.value = state.value.copy(contactsLoading = true, contactsError = null, error = null)
+ if (contacts.isEmpty()) {
+ state.value = state.value.copy(
+ contacts = emptyList(),
+ contactsLoading = false,
+ contactsError = "В телефонной книге нет контактов с номерами"
+ )
+ return@launch
+ }
+ try {
+ val session = requireSession()
+ val imported = repository.importContacts(session, contacts)
+ state.value = state.value.copy(
+ contacts = imported.sortedBy { it.displayName.lowercase() },
+ contactsLoading = false,
+ contactsError = if (imported.isEmpty()) {
+ "MAX не сопоставил ни один из ${contacts.size} номеров телефонной книги"
+ } else {
+ null
+ },
+ updateMessage = "MAX сопоставил контактов: ${imported.size}"
+ )
+ } catch (error: Throwable) {
+ if (error is CancellationException) throw error
+ Log.w(LogTag, "Phone contacts import failed", error)
+ state.value = state.value.copy(
+ contactsLoading = false,
+ contactsError = error.message ?: "Не удалось синхронизировать контакты"
+ )
+ }
+ }
+
+ fun addContact(contact: ContactDto) = launchLoading(false) {
+ val session = requireSession()
+ val updated = repository.addContact(session, contact.userId)
+ state.value = state.value.copy(
+ contacts = state.value.contacts.map { if (it.userId == updated.userId) updated else it }
+ )
+ }
+
+ fun removeContact(contact: ContactDto) = launchLoading(false) {
+ val session = requireSession()
+ repository.removeContact(session, contact.userId)
+ state.value = state.value.copy(
+ contacts = state.value.contacts.map {
+ if (it.userId == contact.userId) it.copy(isSavedContact = false) else it
+ }
+ )
+ }
+
fun openChat(chat: ChatDto) {
saveCurrentDraft()
chatSearchJob?.cancel()
@@ -550,6 +609,17 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
refreshChats(showLoading = false)
}
+ fun deleteSelectedChats() = launchLoading(false) {
+ val session = requireSession()
+ val ids = state.value.selectedChatIds
+ if (ids.isEmpty()) return@launchLoading
+ repository.deleteChats(session, ids)
+ state.value = state.value.copy(
+ chats = state.value.chats.filterNot { it.id in ids },
+ selectedChatIds = emptySet()
+ )
+ }
+
fun loadMessages(showLoading: Boolean = true, forceHydrate: Boolean = false) = launchLoading(showLoading) {
if (!showLoading && state.value.sendingMessage) {
return@launchLoading
diff --git a/pymax-worker/src/server.py b/pymax-worker/src/server.py
index ca9eea0..7770551 100644
--- a/pymax-worker/src/server.py
+++ b/pymax-worker/src/server.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import base64
+import json
import mimetypes
import os
import pathlib
@@ -18,12 +19,14 @@ from typing import Any
import aiohttp
from aiohttp import web
from pymax import Client, ExtraConfig, File, Photo, SyncOverrides, Video
+from pymax.types import ContactInfo
PORT = int(os.environ.get("PORT", "3002"))
PHONE_NUMBER = os.environ.get("PYMAX_PHONE_NUMBER") or os.environ.get("QMAX_MAX_PHONE_NUMBER") or ""
SESSION_DIR = pathlib.Path(os.environ.get("PYMAX_SESSION_DIR", "/data/pymax-session"))
SESSION_NAME = os.environ.get("PYMAX_SESSION_NAME", "session.db")
+PHONE_CONTACT_IDS_FILE = SESSION_DIR / "phone-contact-ids.json"
CHAT_FETCH_LIMIT = int(os.environ.get("PYMAX_CHAT_FETCH_LIMIT", "350"))
HISTORY_LIMIT = int(os.environ.get("PYMAX_HISTORY_LIMIT", "80"))
SEND_ROOTS = [pathlib.Path(p) for p in os.environ.get("PYMAX_SEND_ROOTS", "/qmax-data:/tmp").split(":") if p]
@@ -56,6 +59,11 @@ def normalize_phone(phone: str) -> str:
return phone.strip()
+def phone_key(phone: Any) -> str:
+ normalized = normalize_phone(str(phone or ""))
+ return "".join(ch for ch in normalized if ch.isdigit())
+
+
def is_expired_session_error(error: str | None) -> bool:
text = (error or "").lower()
return any(
@@ -253,7 +261,13 @@ def user_avatar_url(user: Any) -> str | None:
return first_text(data.get("base_url"), data.get("base_raw_url"))
-def contact_result(contact: Any, me_id: int, external_chat_id: Any | None = None) -> dict[str, Any] | None:
+def contact_result(
+ contact: Any,
+ me_id: int,
+ external_chat_id: Any | None = None,
+ *,
+ is_saved_contact: bool = False,
+) -> dict[str, Any] | None:
data = dump_model(contact)
contact_id = coerce_int(data.get("id") or getattr(contact, "id", None))
if contact_id is None or contact_id <= 0 or contact_id == me_id:
@@ -265,6 +279,7 @@ def contact_result(contact: Any, me_id: int, external_chat_id: Any | None = None
"avatarUrl": user_avatar_url(contact),
"phoneNumber": first_text(data.get("phone")),
"status": first_text(data.get("status"), data.get("description")),
+ "isSavedContact": is_saved_contact,
}
@@ -523,10 +538,6 @@ def normalize_chat_kind(chat: Any) -> str:
return "MaxDialog"
-def is_direct_dialog(chat: Any) -> bool:
- return value_name(getattr(chat, "type", "")).upper() == "DIALOG"
-
-
def normalize_attachment_kind(att: Any) -> str:
data = dump_model(att)
raw_type = value_name(data.get("type") or data.get("_type") or getattr(att, "type", "")).lower()
@@ -935,27 +946,151 @@ async def contacts(_request: web.Request) -> web.Response:
me_id = get_me_user_id(client)
if me_id is None:
return json_response({"success": False, "error": "MAX profile has no user id."}, status=503)
-
- by_user_id: dict[str, dict[str, Any]] = {}
- for contact in client.contacts:
- normalized = contact_result(contact, me_id) if contact is not None else None
- if normalized is not None:
- by_user_id[normalized["userId"]] = normalized
-
- chats = await fetch_chat_pages(client, CHAT_FETCH_LIMIT)
- direct_chats = [chat for chat in chats if is_direct_dialog(chat)]
- user_map = await build_user_map(client, direct_chats)
- for chat in direct_chats:
- contact = chat_other_user(chat, user_map, me_id)
- normalized = contact_result(contact, me_id, getattr(chat, "id", None)) if contact is not None else None
- if normalized is not None:
- by_user_id.setdefault(normalized["userId"], normalized)
-
- result = list(by_user_id.values())
+ matched_ids = load_phone_contact_ids()
+ if not matched_ids:
+ return json_response([])
+ users = await client.get_users(sorted(matched_ids))
+ saved_ids = {
+ coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None))
+ for contact in client.contacts
+ if contact is not None
+ }
+ result = [
+ normalized
+ for contact in users
+ if (normalized := contact_result(
+ contact,
+ me_id,
+ is_saved_contact=coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None)) in saved_ids,
+ )) is not None
+ ]
result.sort(key=lambda item: item["displayName"].casefold())
return json_response(result)
+def load_phone_contact_ids() -> set[int]:
+ try:
+ values = json.loads(PHONE_CONTACT_IDS_FILE.read_text(encoding="utf-8"))
+ except (OSError, ValueError, TypeError):
+ return set()
+ return {value for item in values if (value := coerce_int(item)) is not None and value > 0}
+
+
+def save_phone_contact_ids(contact_ids: set[int]) -> None:
+ SESSION_DIR.mkdir(parents=True, exist_ok=True)
+ part_path = PHONE_CONTACT_IDS_FILE.with_suffix(".json.part")
+ part_path.write_text(json.dumps(sorted(contact_ids)), encoding="utf-8")
+ part_path.replace(PHONE_CONTACT_IDS_FILE)
+
+
+def append_saved_contacts(client: Client, contacts: list[Any]) -> None:
+ saved = client.contacts
+ saved_ids = {
+ coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None))
+ for contact in saved
+ if contact is not None
+ }
+ for contact in contacts:
+ contact_id = coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None))
+ if contact_id is not None and contact_id not in saved_ids:
+ saved.append(contact)
+ saved_ids.add(contact_id)
+
+
+@route_errors
+async def contacts_import(request: web.Request) -> web.Response:
+ data = await read_json(request)
+ raw_contacts = data.get("contacts") if isinstance(data.get("contacts"), list) else []
+ if not raw_contacts:
+ return json_response({"success": False, "error": "contacts are required"}, status=400)
+ if len(raw_contacts) > 5000:
+ return json_response({"success": False, "error": "contacts limit is 5000"}, status=400)
+
+ contacts_to_import: list[ContactInfo] = []
+ requested_phone_keys: set[str] = set()
+ for item in raw_contacts:
+ if not isinstance(item, dict):
+ continue
+ phone = normalize_phone(str(item.get("phoneNumber") or item.get("phone") or "").strip())
+ first_name = str(item.get("firstName") or item.get("name") or phone).strip()
+ last_name = str(item.get("lastName") or "").strip() or None
+ key = phone_key(phone)
+ if phone and key and key not in requested_phone_keys:
+ requested_phone_keys.add(key)
+ contacts_to_import.append(ContactInfo(phone=phone, first_name=first_name or phone, last_name=last_name))
+ if not contacts_to_import:
+ return json_response({"success": False, "error": "contacts contain no phone numbers"}, status=400)
+
+ client = await runtime.get_client()
+ imported = list(await client.import_contacts(contacts_to_import) or [])
+ append_saved_contacts(client, imported)
+
+ # PyMax's SYNC response may omit contacts that were already saved in MAX.
+ # Merge those cached contacts back by phone so a repeated phone-book sync
+ # produces the same intersection instead of replacing it with an empty set.
+ matched_by_id: dict[int, Any] = {}
+ for contact in imported:
+ contact_id = coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None))
+ if contact_id is not None and contact_id > 0:
+ matched_by_id[contact_id] = contact
+ for contact in client.contacts:
+ contact_data = dump_model(contact)
+ contact_id = coerce_int(contact_data.get("id") or getattr(contact, "id", None))
+ if (
+ contact_id is not None
+ and contact_id > 0
+ and phone_key(contact_data.get("phone") or getattr(contact, "phone", None)) in requested_phone_keys
+ ):
+ matched_by_id[contact_id] = contact
+
+ matched = list(matched_by_id.values())
+ imported_ids = {
+ contact_id
+ for contact in matched
+ if (contact_id := coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None))) is not None
+ and contact_id > 0
+ }
+ save_phone_contact_ids(imported_ids)
+ me_id = get_me_user_id(client)
+ result = [
+ normalized
+ for contact in matched
+ if me_id is not None
+ if (normalized := contact_result(contact, me_id, is_saved_contact=True)) is not None
+ ]
+ return json_response(result)
+
+
+@route_errors
+async def contacts_add(request: web.Request) -> web.Response:
+ data = await read_json(request)
+ user_id = coerce_int(data.get("userId"))
+ if user_id is None or user_id <= 0:
+ return json_response({"success": False, "error": "valid userId is required"}, status=400)
+ client = await runtime.get_client()
+ contact = await client.add_contact(user_id)
+ append_saved_contacts(client, [contact])
+ me_id = get_me_user_id(client)
+ normalized = contact_result(contact, me_id, is_saved_contact=True) if me_id is not None else None
+ return json_response(normalized or {"success": False, "error": "contact could not be normalized"})
+
+
+@route_errors
+async def contacts_remove(request: web.Request) -> web.Response:
+ data = await read_json(request)
+ user_id = coerce_int(data.get("userId"))
+ if user_id is None or user_id <= 0:
+ return json_response({"success": False, "error": "valid userId is required"}, status=400)
+ client = await runtime.get_client()
+ await client.remove_contact(user_id)
+ client.contacts[:] = [
+ contact
+ for contact in client.contacts
+ if contact is not None and coerce_int(dump_model(contact).get("id") or getattr(contact, "id", None)) != user_id
+ ]
+ return json_response({"success": True, "error": None})
+
+
@route_errors
async def chat_history(request: web.Request) -> web.Response:
data = await read_json(request)
@@ -1035,6 +1170,20 @@ async def disabled_action(_request: web.Request) -> web.Response:
return json_response({"success": False, "error": "Not implemented in PyMax POC worker."})
+@route_errors
+async def chat_delete(request: web.Request) -> web.Response:
+ data = await read_json(request)
+ client = await runtime.get_client()
+ chat_id = parse_chat_id(data.get("externalChatId") or data.get("chatUrl"))
+ chat = await client.get_chat(chat_id)
+ await client.delete_chat(
+ chat_id,
+ last_event_time=getattr(chat, "last_event_time", None),
+ for_all=False,
+ )
+ return json_response({"success": True, "error": None})
+
+
@route_errors
async def send_text(request: web.Request) -> web.Response:
data = await read_json(request)
@@ -1083,13 +1232,16 @@ def create_app() -> web.Application:
app.router.add_get("/snapshot", snapshot)
app.router.add_get("/updates", updates)
app.router.add_get("/contacts", contacts)
+ app.router.add_post("/contacts/import", contacts_import)
+ app.router.add_post("/contacts/add", contacts_add)
+ app.router.add_post("/contacts/remove", contacts_remove)
app.router.add_post("/chat/history", chat_history)
app.router.add_post("/chat/resolve-url", resolve_chat_url)
app.router.add_post("/channels/search", channels_search)
app.router.add_post("/channels/join", channels_join)
app.router.add_post("/chat/presence", chat_presence)
app.router.add_post("/chat/clear-history", disabled_action)
- app.router.add_post("/chat/delete", disabled_action)
+ app.router.add_post("/chat/delete", chat_delete)
app.router.add_post("/send/text", send_text)
app.router.add_post("/send/attachment", send_attachment)
app.router.add_post("/message/edit", disabled_action)
diff --git a/server/QMax.Api/Contracts/ChatContracts.cs b/server/QMax.Api/Contracts/ChatContracts.cs
index d6fac37..7f8380b 100644
--- a/server/QMax.Api/Contracts/ChatContracts.cs
+++ b/server/QMax.Api/Contracts/ChatContracts.cs
@@ -59,4 +59,9 @@ public sealed record ContactDto(
string DisplayName,
string? AvatarUrl,
string? PhoneNumber,
- string? Status);
+ string? Status,
+ bool IsSavedContact);
+
+public sealed record PhoneContactDto(string PhoneNumber, string FirstName, string? LastName = null);
+public sealed record ImportPhoneContactsRequest(IReadOnlyList Contacts);
+public sealed record ContactUserRequest(string UserId);
diff --git a/server/QMax.Api/Controllers/ChatsController.cs b/server/QMax.Api/Controllers/ChatsController.cs
index 530a4d7..e242369 100644
--- a/server/QMax.Api/Controllers/ChatsController.cs
+++ b/server/QMax.Api/Controllers/ChatsController.cs
@@ -51,8 +51,18 @@ public sealed class ChatsController(
Kind = ChatKind.MaxDialog
};
db.Chats.Add(chat);
- await db.SaveChangesAsync(cancellationToken);
}
+ else
+ {
+ chat.Title = request.Title;
+ chat.AvatarUrl = request.AvatarUrl;
+ chat.DeletedAt = null;
+ chat.PendingMaxAction = null;
+ chat.PendingMaxActionError = null;
+ chat.UpdatedAt = DateTimeOffset.UtcNow;
+ }
+
+ await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
return projection.ToDto(chat);
@@ -274,7 +284,7 @@ public sealed class ChatsController(
}
[HttpPost("delete")]
- public IActionResult DeleteChats(ChatBulkActionRequest request)
+ public async Task DeleteChats(ChatBulkActionRequest request, CancellationToken cancellationToken)
{
var chatIds = request.ChatIds.Distinct().ToArray();
if (chatIds.Length == 0)
@@ -282,7 +292,43 @@ public sealed class ChatsController(
return BadRequest("chatIds are required.");
}
- return BadRequest("Chat deletion is disabled because MAX does not remove chats from the web client.");
+ var chats = await LoadChatsWithAttachmentsAsync(chatIds, cancellationToken);
+ if (chats.Count != chatIds.Length || chats.Any(chat => chat.DeletedAt is not null))
+ {
+ 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 now = DateTimeOffset.UtcNow;
+ 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.DeletedAt = now;
+ chat.UpdatedAt = now;
+ chat.PendingMaxAction = null;
+ chat.PendingMaxActionError = null;
+ }
+
+ await db.SaveChangesAsync(cancellationToken);
+ DeleteFiles(files);
+ await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
+ return NoContent();
}
[HttpGet("{chatId:guid}/search")]
diff --git a/server/QMax.Api/Controllers/ContactsController.cs b/server/QMax.Api/Controllers/ContactsController.cs
index cdfcf00..3e30703 100644
--- a/server/QMax.Api/Controllers/ContactsController.cs
+++ b/server/QMax.Api/Controllers/ContactsController.cs
@@ -21,8 +21,62 @@ public sealed class ContactsController(IMaxBridgeClient maxBridge) : ControllerB
contact.DisplayName,
contact.AvatarUrl,
contact.PhoneNumber,
- contact.Status))
+ contact.Status,
+ contact.IsSavedContact))
.OrderBy(contact => contact.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
}
+
+ [HttpPost("import")]
+ public async Task>> ImportContacts(
+ ImportPhoneContactsRequest request,
+ CancellationToken cancellationToken)
+ {
+ var contacts = request.Contacts
+ .Where(contact => !string.IsNullOrWhiteSpace(contact.PhoneNumber))
+ .Take(5000)
+ .Select(contact => new MaxPhoneContact(
+ contact.PhoneNumber.Trim(),
+ string.IsNullOrWhiteSpace(contact.FirstName) ? contact.PhoneNumber.Trim() : contact.FirstName.Trim(),
+ contact.LastName?.Trim()))
+ .ToArray();
+ if (contacts.Length == 0)
+ {
+ return BadRequest("contacts with phone numbers are required.");
+ }
+
+ var imported = await maxBridge.ImportContactsAsync(contacts, cancellationToken);
+ return imported.Select(ToDto).ToArray();
+ }
+
+ [HttpPost("add")]
+ public async Task> AddContact(ContactUserRequest request, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(request.UserId))
+ {
+ return BadRequest("userId is required.");
+ }
+ var contact = await maxBridge.AddContactAsync(request.UserId.Trim(), cancellationToken);
+ return contact is null ? BadRequest("MAX did not add the contact.") : ToDto(contact);
+ }
+
+ [HttpPost("remove")]
+ public async Task RemoveContact(ContactUserRequest request, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(request.UserId))
+ {
+ return BadRequest("userId is required.");
+ }
+ var result = await maxBridge.RemoveContactAsync(request.UserId.Trim(), cancellationToken);
+ return result.Success ? NoContent() : BadRequest(result.Error ?? "MAX did not remove the contact.");
+ }
+
+ private static ContactDto ToDto(MaxContact contact) => new(
+ contact.UserId,
+ contact.ExternalChatId,
+ contact.DisplayName,
+ contact.AvatarUrl,
+ contact.PhoneNumber,
+ contact.Status,
+ contact.IsSavedContact);
}
diff --git a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs
index 2c5cf9f..3f5f017 100644
--- a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs
+++ b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs
@@ -11,6 +11,18 @@ public interface IMaxBridgeClient
{
return Task.FromResult>(Array.Empty());
}
+ Task> ImportContactsAsync(IReadOnlyList contacts, CancellationToken cancellationToken)
+ {
+ return Task.FromResult>(Array.Empty());
+ }
+ Task AddContactAsync(string userId, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(null);
+ }
+ Task RemoveContactAsync(string userId, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new MaxActionResult(false, "Contact removal is not supported."));
+ }
Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken);
diff --git a/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs b/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs
index 16c90d9..abe1648 100644
--- a/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs
+++ b/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs
@@ -80,4 +80,7 @@ public sealed record MaxContact(
string DisplayName,
string? AvatarUrl,
string? PhoneNumber,
- string? Status);
+ string? Status,
+ bool IsSavedContact = false);
+
+public sealed record MaxPhoneContact(string PhoneNumber, string FirstName, string? LastName);
diff --git a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs
index 3011249..25bf91d 100644
--- a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs
+++ b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs
@@ -48,10 +48,25 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
public Task> FetchContactsAsync(CancellationToken cancellationToken)
{
return Task.FromResult>([
- new MaxContact("mock-user", "mock-direct", "Mock contact", null, "+70000000000", "online")
+ new MaxContact("mock-user", "mock-direct", "Mock contact", null, "+70000000000", "online", true)
]);
}
+ public Task> ImportContactsAsync(IReadOnlyList contacts, CancellationToken cancellationToken)
+ {
+ return FetchContactsAsync(cancellationToken);
+ }
+
+ public async Task AddContactAsync(string userId, CancellationToken cancellationToken)
+ {
+ return (await FetchContactsAsync(cancellationToken)).FirstOrDefault();
+ }
+
+ public Task RemoveContactAsync(string userId, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new MaxActionResult(true, null));
+ }
+
public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId);
@@ -75,9 +90,7 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
- return Task.FromResult(new MaxActionResult(
- false,
- "Chat deletion is disabled because MAX does not remove chats from the web client."));
+ return Task.FromResult(new MaxActionResult(true, null));
}
public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
diff --git a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs
index 0994b14..7c0570f 100644
--- a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs
+++ b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs
@@ -49,6 +49,29 @@ public sealed class WorkerMaxBridgeClient(
?? Array.Empty();
}
+ public async Task> ImportContactsAsync(
+ IReadOnlyList contacts,
+ CancellationToken cancellationToken)
+ {
+ return await SendAsync>(
+ HttpMethod.Post,
+ "/contacts/import",
+ new { contacts },
+ cancellationToken)
+ ?? Array.Empty();
+ }
+
+ public async Task AddContactAsync(string userId, CancellationToken cancellationToken)
+ {
+ return await SendAsync(HttpMethod.Post, "/contacts/add", new { userId }, cancellationToken);
+ }
+
+ public async Task RemoveContactAsync(string userId, CancellationToken cancellationToken)
+ {
+ return await SendAsync(HttpMethod.Post, "/contacts/remove", new { userId }, cancellationToken)
+ ?? new MaxActionResult(false, "Worker returned an empty contact removal result.");
+ }
+
public async Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return await SendAsync(HttpMethod.Post, "/chat/history", new { externalChatId, chatUrl }, cancellationToken);
@@ -75,11 +98,14 @@ public sealed class WorkerMaxBridgeClient(
?? new MaxActionResult(false, "Worker returned an empty clear history result.");
}
- public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
+ public async Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
- return Task.FromResult(new MaxActionResult(
- false,
- "Chat deletion is disabled because MAX does not remove chats from the web client."));
+ return await SendAsync(
+ HttpMethod.Post,
+ "/chat/delete",
+ new { externalChatId, chatUrl },
+ cancellationToken)
+ ?? new MaxActionResult(false, "Worker returned an empty chat deletion result.");
}
public async Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
diff --git a/server/QMax.Api/Services/MaxOutboxService.cs b/server/QMax.Api/Services/MaxOutboxService.cs
index 116f3ec..693ad16 100644
--- a/server/QMax.Api/Services/MaxOutboxService.cs
+++ b/server/QMax.Api/Services/MaxOutboxService.cs
@@ -125,9 +125,10 @@ public sealed class MaxOutboxService(
chat,
(externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken),
cancellationToken),
- ChatPendingMaxAction.DeleteChat => new MaxActionResult(
- false,
- "Chat deletion is disabled because MAX does not remove chats from the web client."),
+ ChatPendingMaxAction.DeleteChat => await ApplyMaxChatActionAsync(
+ chat,
+ (externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken),
+ cancellationToken),
_ => new MaxActionResult(false, $"Unsupported chat action: {pendingAction.Value}.")
};
}
diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs
index ba75963..a81f578 100644
--- a/tests/QMax.Tests/ApiSmokeTests.cs
+++ b/tests/QMax.Tests/ApiSmokeTests.cs
@@ -125,6 +125,14 @@ public sealed class ApiSmokeTests : IDisposable
var contact = Assert.Single(contacts!);
Assert.Equal("mock-direct", contact.ExternalChatId);
Assert.Equal("Mock contact", contact.DisplayName);
+ Assert.True(contact.IsSavedContact);
+
+ var importedResponse = await client.PostAsJsonAsync(
+ "/api/contacts/import",
+ new ImportPhoneContactsRequest([new PhoneContactDto("+70000000000", "Mock contact")]));
+ await AssertStatusAsync(HttpStatusCode.OK, importedResponse);
+ var imported = await importedResponse.Content.ReadFromJsonAsync(JsonOptions);
+ Assert.Single(imported!);
var response = await client.PostAsJsonAsync(
"/api/chats/direct",
@@ -134,6 +142,14 @@ public sealed class ApiSmokeTests : IDisposable
Assert.NotNull(chat);
Assert.Equal(contact.ExternalChatId, chat!.ExternalId);
Assert.Equal(contact.DisplayName, chat.Title);
+
+ var removeContact = await client.PostAsJsonAsync("/api/contacts/remove", new ContactUserRequest(contact.UserId));
+ await AssertStatusAsync(HttpStatusCode.NoContent, removeContact);
+
+ var deleteChat = await client.PostAsJsonAsync("/api/chats/delete", new ChatBulkActionRequest([chat.Id]));
+ await AssertStatusAsync(HttpStatusCode.NoContent, deleteChat);
+ var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions);
+ Assert.DoesNotContain(chats!, candidate => candidate.Id == chat.Id);
}
[Fact]