Add MAX contacts and direct chat creation

This commit is contained in:
Курнат Андрей
2026-07-12 22:51:24 +03:00
parent 51d4c7dd1d
commit ff3f139889
16 changed files with 630 additions and 34 deletions
+2 -2
View File
@@ -22,8 +22,8 @@ android {
applicationId = "xyz.kusoft.qmax"
minSdk = 26
targetSdk = 36
versionCode = 51
versionName = "0.1.50"
versionCode = 52
versionName = "0.1.51"
buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"")
buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"")
@@ -165,6 +165,7 @@ import kotlinx.coroutines.withContext
import xyz.kusoft.qmax.core.push.ForegroundChatTracker
import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.ChatDto
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
@@ -380,6 +381,17 @@ private fun ChatListScreen(vm: QMaxViewModel) {
}
}
}
val filteredContacts = remember(state.contacts, state.searchQuery) {
val query = state.searchQuery.trim()
if (query.isBlank()) {
state.contacts
} else {
state.contacts.filter { contact ->
contact.displayName.contains(query, ignoreCase = true) ||
(contact.phoneNumber?.contains(query, ignoreCase = true) == true)
}
}
}
LaunchedEffect(activeTab, state.searchQuery) {
if (activeTab == MainTab.Channels) {
@@ -392,10 +404,14 @@ private fun ChatListScreen(vm: QMaxViewModel) {
TelegramChatListContent(
state = state,
filteredChats = filteredChats,
filteredContacts = filteredContacts,
activeTab = activeTab,
menuOpen = menuOpen,
onMenuOpenChange = { menuOpen = it },
onRefresh = vm::loadChats,
onRefresh = {
vm.loadChats()
vm.loadContacts()
},
onCheckUpdates = vm::checkArgusUpdate,
onRefreshMaxStatus = vm::loadMaxStatus,
onStartMaxLogin = vm::startMaxLogin,
@@ -410,7 +426,11 @@ private fun ChatListScreen(vm: QMaxViewModel) {
vm.clearChatSelection()
},
onOpenChat = vm::openChat,
onNewChat = { newChatOpen = true },
onOpenContact = vm::openContact,
onNewChat = {
activeTab = MainTab.Contacts
vm.updateSearchQuery("")
},
onCacheAvatar = vm::cacheAvatar,
selectionMenuOpen = selectionMenuOpen,
onSelectionMenuOpenChange = { selectionMenuOpen = it },
@@ -623,6 +643,7 @@ private fun ChatListScreen(vm: QMaxViewModel) {
private fun TelegramChatListContent(
state: QMaxUiState,
filteredChats: List<ChatDto>,
filteredContacts: List<ContactDto>,
activeTab: MainTab,
menuOpen: Boolean,
onMenuOpenChange: (Boolean) -> Unit,
@@ -637,6 +658,7 @@ private fun TelegramChatListContent(
onSubscribeChannel: (MaxChannelSearchResultDto) -> Unit,
onTabSelected: (MainTab) -> Unit,
onOpenChat: (ChatDto) -> Unit,
onOpenContact: (ContactDto) -> Unit,
onNewChat: () -> Unit,
onCacheAvatar: (String) -> Unit,
selectionMenuOpen: Boolean,
@@ -659,7 +681,7 @@ private fun TelegramChatListContent(
}
val emptyText = when (activeTab) {
MainTab.Chats -> null
MainTab.Contacts -> "Контактов пока нет"
MainTab.Contacts -> if (state.contactsLoading) "Загрузка контактов..." else "Контактов пока нет"
MainTab.Channels -> "Каналов пока нет"
MainTab.Settings -> null
}
@@ -687,7 +709,7 @@ private fun TelegramChatListContent(
onLogout = onLogout
)
}
ErrorLine(state.error)
ErrorLine(if (activeTab == MainTab.Contacts) state.contactsError ?: state.error else state.error)
if (activeTab != MainTab.Settings && state.maxStatus?.isAuthorized == false) {
MaxSessionExpiredBanner(onClick = { onTabSelected(MainTab.Settings) })
}
@@ -705,11 +727,16 @@ private fun TelegramChatListContent(
} else {
ChatListSearchBar(
query = state.searchQuery,
placeholder = if (activeTab == MainTab.Channels) "Поиск каналов" else "Поиск чатов",
placeholder = when (activeTab) {
MainTab.Channels -> "Поиск каналов"
MainTab.Contacts -> "Поиск контактов"
else -> "Поиск чатов"
},
onQuery = onSearch
)
}
if (listTab && filteredChats.isEmpty() && !showChannelSearch) {
val visibleListEmpty = if (activeTab == MainTab.Contacts) filteredContacts.isEmpty() else filteredChats.isEmpty()
if (listTab && visibleListEmpty && !showChannelSearch) {
if (state.searchQuery.isNotBlank()) {
EmptyState("Ничего не найдено")
} else {
@@ -728,24 +755,36 @@ private fun TelegramChatListContent(
)
}
}
items(filteredChats, key = { it.id }) { chat ->
TelegramChatRow(
chat = chat,
session = state.session,
cachedAvatarPaths = state.cachedAvatarPaths,
draftText = state.drafts[chat.id],
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)
if (activeTab == MainTab.Contacts) {
items(filteredContacts, key = { it.userId }) { contact ->
ContactRow(
contact = contact,
session = state.session,
cachedAvatarPaths = state.cachedAvatarPaths,
onCacheAvatar = onCacheAvatar,
onClick = { onOpenContact(contact) }
)
}
} else {
items(filteredChats, key = { it.id }) { chat ->
TelegramChatRow(
chat = chat,
session = state.session,
cachedAvatarPaths = state.cachedAvatarPaths,
draftText = state.drafts[chat.id],
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)
}
}
}
)
)
}
}
}
}
@@ -959,6 +998,55 @@ private fun ChatListSearchBar(query: String, placeholder: String, onQuery: (Stri
}
}
@Composable
private fun ContactRow(
contact: ContactDto,
session: QMaxSession?,
cachedAvatarPaths: Map<String, String>,
onCacheAvatar: (String) -> Unit,
onClick: () -> Unit
) {
val subtitle = contact.status?.takeIf { it.isNotBlank() }
?: contact.phoneNumber?.takeIf { it.isNotBlank() }
?: "Нажмите, чтобы открыть чат"
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 26.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(
title = contact.displayName,
avatarUrl = contact.avatarUrl,
session = session,
size = 58.dp,
cachedAvatarPaths = cachedAvatarPaths,
onCacheAvatar = onCacheAvatar
)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
contact.displayName,
color = QMaxText,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(2.dp))
Text(
subtitle,
color = QMaxMuted,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Icon(Icons.Filled.Chat, contentDescription = "Открыть чат", tint = QMaxBlue)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun TelegramChatRow(
@@ -26,6 +26,7 @@ import xyz.kusoft.qmax.core.model.AttachmentDto
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.MaxBridgeStatusDto
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
import xyz.kusoft.qmax.core.model.MessageDto
@@ -91,8 +92,14 @@ class QMaxRepository(
return chats
}
suspend fun createChat(session: QMaxSession, externalId: String, title: String): ChatDto {
val chat = withFreshSession(session) { api.createDirect(it.serverUrl, it.accessToken, externalId, title) }
suspend fun contacts(session: QMaxSession): List<ContactDto> {
return withFreshSession(session) { api.contacts(it.serverUrl, it.accessToken) }
}
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)
}
val updatedChats = (cachedChats(session).filterNot { it.id == chat.id } + chat)
.let(::orderedChats)
messageCache.saveChats(session, updatedChats)
@@ -49,6 +49,16 @@ data class ChatPresenceDto(
val updatedAt: String? = null
)
@Serializable
data class ContactDto(
val userId: String,
val externalChatId: String,
val displayName: String,
val avatarUrl: String? = null,
val phoneNumber: String? = null,
val status: String? = null
)
@Serializable
data class MessageDto(
val id: String,
@@ -26,6 +26,7 @@ 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
import xyz.kusoft.qmax.core.model.ContactDto
import xyz.kusoft.qmax.core.model.DeviceLoginRequest
import xyz.kusoft.qmax.core.model.EditMessageRequest
import xyz.kusoft.qmax.core.model.ForwardMessageRequest
@@ -77,8 +78,18 @@ class QMaxApi {
return get(sessionServerUrl, "/api/chats", token)
}
suspend fun createDirect(sessionServerUrl: String, token: String, externalChatId: String, title: String): ChatDto {
return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title))
suspend fun contacts(sessionServerUrl: String, token: String): List<ContactDto> {
return get(sessionServerUrl, "/api/contacts", token)
}
suspend fun createDirect(
sessionServerUrl: String,
token: String,
externalChatId: String,
title: String,
avatarUrl: String? = null
): ChatDto {
return post(sessionServerUrl, "/api/chats/direct", token, CreateDirectChatRequest(externalChatId, title, avatarUrl))
}
suspend fun messages(sessionServerUrl: String, token: String, chatId: String, sync: Boolean = false): List<MessageDto> {
@@ -16,6 +16,7 @@ import xyz.kusoft.qmax.BuildConfig
import xyz.kusoft.qmax.core.QMaxRepository
import xyz.kusoft.qmax.core.model.AttachmentDto
import xyz.kusoft.qmax.core.model.ChatDto
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.MessageDeletedDto
@@ -32,6 +33,9 @@ data class QMaxUiState(
val pairingCode: String = BuildConfig.QMAX_DEFAULT_PAIRING_CODE,
val session: QMaxSession? = null,
val chats: List<ChatDto> = emptyList(),
val contacts: List<ContactDto> = emptyList(),
val contactsLoading: Boolean = false,
val contactsError: String? = null,
val selectedChat: ChatDto? = null,
val messages: List<MessageDto> = emptyList(),
val chatSearchResults: List<MessageDto> = emptyList(),
@@ -105,12 +109,16 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
serverUrl = session?.serverUrl ?: state.value.serverUrl,
cachedImagePaths = if (sessionChanged) emptyMap() else state.value.cachedImagePaths,
cachedAvatarPaths = if (sessionChanged) emptyMap() else state.value.cachedAvatarPaths,
contacts = if (sessionChanged) emptyList() else state.value.contacts,
contactsLoading = if (sessionChanged) false else state.value.contactsLoading,
contactsError = if (sessionChanged) null else state.value.contactsError,
selectedChatIds = if (sessionChanged) emptySet() else state.value.selectedChatIds
)
if (session != null) {
autoLoginJob?.cancel()
restoreCachedChats(session)
loadChats()
loadContacts()
connectRealtime(session)
registerPushDevice(session)
startMaxStatusPolling(session)
@@ -266,6 +274,36 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
fun loadChats() = refreshChats()
fun loadContacts() {
val session = state.value.session ?: return
viewModelScope.launch {
state.value = state.value.copy(contactsLoading = true, contactsError = null)
runCatching { repository.contacts(session) }
.onSuccess { contacts ->
if (state.value.session?.serverUrl == session.serverUrl &&
state.value.session?.userName == session.userName
) {
state.value = state.value.copy(
contacts = contacts,
contactsLoading = false,
contactsError = null
)
}
}
.onFailure { error ->
if (error is CancellationException) throw error
if (state.value.session?.serverUrl == session.serverUrl &&
state.value.session?.userName == session.userName
) {
state.value = state.value.copy(
contactsLoading = false,
contactsError = error.message ?: "Не удалось загрузить контакты"
)
}
}
}
}
private suspend fun restoreCachedChats(session: QMaxSession) {
runCatching {
repository.cachedChats(session)
@@ -366,6 +404,22 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
loadChats()
}
fun openContact(contact: ContactDto) = launchLoading(false) {
val session = requireSession()
val existing = state.value.chats.firstOrNull { it.externalId == contact.externalChatId }
val chat = existing ?: repository.createChat(
session,
contact.externalChatId,
contact.displayName,
contact.avatarUrl
)
if (existing == null) {
state.value = state.value.copy(chats = normalizeChats(state.value.chats + chat))
}
openChat(chat)
loadChats()
}
fun openChat(chat: ChatDto) {
saveCurrentDraft()
chatSearchJob?.cancel()