Add MAX contacts and direct chat creation
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user