Add channel search and subscription
This commit is contained in:
@@ -165,6 +165,7 @@ 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.MaxBridgeStatusDto
|
||||
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
|
||||
import xyz.kusoft.qmax.core.model.MessageDto
|
||||
import xyz.kusoft.qmax.core.model.QMaxSession
|
||||
import xyz.kusoft.qmax.ui.QMaxUiState
|
||||
@@ -363,6 +364,14 @@ private fun ChatListScreen(vm: QMaxViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(activeTab, state.searchQuery) {
|
||||
if (activeTab == MainTab.Channels) {
|
||||
vm.searchMaxChannels(state.searchQuery)
|
||||
} else {
|
||||
vm.searchMaxChannels("")
|
||||
}
|
||||
}
|
||||
|
||||
TelegramChatListContent(
|
||||
state = state,
|
||||
filteredChats = filteredChats,
|
||||
@@ -377,6 +386,7 @@ private fun ChatListScreen(vm: QMaxViewModel) {
|
||||
onSubmitMaxCode = vm::submitMaxCode,
|
||||
onLogout = vm::logout,
|
||||
onSearch = vm::updateSearchQuery,
|
||||
onSubscribeChannel = vm::subscribeMaxChannel,
|
||||
onTabSelected = { tab ->
|
||||
activeTab = tab
|
||||
vm.updateSearchQuery("")
|
||||
@@ -607,6 +617,7 @@ private fun TelegramChatListContent(
|
||||
onSubmitMaxCode: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onSubscribeChannel: (MaxChannelSearchResultDto) -> Unit,
|
||||
onTabSelected: (MainTab) -> Unit,
|
||||
onOpenChat: (ChatDto) -> Unit,
|
||||
onNewChat: () -> Unit,
|
||||
@@ -620,6 +631,9 @@ private fun TelegramChatListContent(
|
||||
) {
|
||||
val selectedCount = state.selectedChatIds.size
|
||||
val listTab = activeTab != MainTab.Settings
|
||||
val showChannelSearch = activeTab == MainTab.Channels &&
|
||||
state.searchQuery.trim().isNotBlank() &&
|
||||
(state.channelSearchLoading || !state.channelSearchError.isNullOrBlank() || state.channelSearchResults.any { !it.isSubscribed })
|
||||
val headerTitle = when (activeTab) {
|
||||
MainTab.Chats -> if (state.chatListConnectionIssue) "Соединение..." else "QMAX"
|
||||
MainTab.Contacts -> "Контакты"
|
||||
@@ -672,9 +686,13 @@ private fun TelegramChatListContent(
|
||||
onLogout = onLogout
|
||||
)
|
||||
} else {
|
||||
ChatListSearchBar(query = state.searchQuery, onQuery = onSearch)
|
||||
ChatListSearchBar(
|
||||
query = state.searchQuery,
|
||||
placeholder = if (activeTab == MainTab.Channels) "Поиск каналов" else "Поиск чатов",
|
||||
onQuery = onSearch
|
||||
)
|
||||
}
|
||||
if (listTab && filteredChats.isEmpty()) {
|
||||
if (listTab && filteredChats.isEmpty() && !showChannelSearch) {
|
||||
if (state.searchQuery.isNotBlank()) {
|
||||
EmptyState("Ничего не найдено")
|
||||
} else {
|
||||
@@ -685,6 +703,14 @@ private fun TelegramChatListContent(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 118.dp)
|
||||
) {
|
||||
if (showChannelSearch) {
|
||||
item {
|
||||
ChannelSearchResults(
|
||||
state = state,
|
||||
onSubscribeChannel = onSubscribeChannel
|
||||
)
|
||||
}
|
||||
}
|
||||
items(filteredChats, key = { it.id }) { chat ->
|
||||
TelegramChatRow(
|
||||
chat = chat,
|
||||
@@ -875,7 +901,7 @@ private fun ChatSelectionHeader(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatListSearchBar(query: String, onQuery: (String) -> Unit) {
|
||||
private fun ChatListSearchBar(query: String, placeholder: String, onQuery: (String) -> Unit) {
|
||||
Surface(
|
||||
color = Color(0xFFF1F1F3),
|
||||
shape = RoundedCornerShape(30.dp),
|
||||
@@ -893,7 +919,7 @@ private fun ChatListSearchBar(query: String, onQuery: (String) -> Unit) {
|
||||
value = query,
|
||||
onValueChange = onQuery,
|
||||
placeholder = {
|
||||
Text("Поиск чатов", color = Color(0xFF8A8E93), style = MaterialTheme.typography.titleMedium)
|
||||
Text(placeholder, color = Color(0xFF8A8E93), style = MaterialTheme.typography.titleMedium)
|
||||
},
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
@@ -1198,6 +1224,82 @@ private fun maxStatusHint(status: MaxBridgeStatusDto?): String {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelSearchResults(
|
||||
state: QMaxUiState,
|
||||
onSubscribeChannel: (MaxChannelSearchResultDto) -> Unit
|
||||
) {
|
||||
val joinableResults = state.channelSearchResults.filterNot { it.isSubscribed }
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
if (state.channelSearchLoading) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 28.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(color = QMaxBlue, modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text("Поиск каналов", color = QMaxMuted, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
state.channelSearchError?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
it,
|
||||
color = Color(0xFFC62828),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 28.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
joinableResults.forEach { result ->
|
||||
ChannelSearchRow(result = result, onSubscribe = { onSubscribeChannel(result) })
|
||||
}
|
||||
if (joinableResults.isNotEmpty()) {
|
||||
HorizontalDivider(color = Color(0xFFE7EEF3), modifier = Modifier.padding(start = 88.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelSearchRow(result: MaxChannelSearchResultDto, onSubscribe: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(color = Color(0xFFE6F4F1), shape = CircleShape, modifier = Modifier.size(54.dp)) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
result.title.firstOrNull()?.uppercaseChar()?.toString() ?: "#",
|
||||
color = QMaxBlue,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
result.title,
|
||||
color = QMaxText,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
result.description?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
it,
|
||||
color = QMaxMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Button(onClick = onSubscribe, enabled = !result.isSubscribed) {
|
||||
Text(if (result.isSubscribed) "В списке" else "Подписаться")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TelegramBottomNavigation(
|
||||
modifier: Modifier = Modifier,
|
||||
|
||||
@@ -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.MaxBridgeStatusDto
|
||||
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
|
||||
import xyz.kusoft.qmax.core.model.MessageDto
|
||||
import xyz.kusoft.qmax.core.model.QMaxSession
|
||||
import xyz.kusoft.qmax.core.network.QMaxApi
|
||||
@@ -309,6 +310,18 @@ class QMaxRepository(
|
||||
return withFreshSession(session) { api.submitMaxCode(it.serverUrl, it.accessToken, code) }
|
||||
}
|
||||
|
||||
suspend fun searchMaxChannels(session: QMaxSession, query: String): List<MaxChannelSearchResultDto> {
|
||||
return withFreshSession(session) { api.searchMaxChannels(it.serverUrl, it.accessToken, query) }
|
||||
}
|
||||
|
||||
suspend fun subscribeMaxChannel(session: QMaxSession, link: String): ChatDto {
|
||||
val chat = withFreshSession(session) { api.subscribeMaxChannel(it.serverUrl, it.accessToken, link) }
|
||||
val updatedChats = (cachedChats(session).filterNot { it.id == chat.id } + chat)
|
||||
.let(::orderedChats)
|
||||
messageCache.saveChats(session, updatedChats)
|
||||
return chat
|
||||
}
|
||||
|
||||
suspend fun registerCurrentPushDevice(session: QMaxSession): Boolean {
|
||||
if (!FirebaseBootstrap.ensureInitialized(context)) {
|
||||
Log.w(PushLogTag, "Firebase is not initialized; push device registration skipped")
|
||||
|
||||
@@ -148,6 +148,19 @@ data class BeginMaxLoginRequest(val phoneNumber: String? = null)
|
||||
@Serializable
|
||||
data class SubmitMaxCodeRequest(val code: String)
|
||||
|
||||
@Serializable
|
||||
data class MaxChannelSearchResultDto(
|
||||
val externalId: String,
|
||||
val title: String,
|
||||
val avatarUrl: String? = null,
|
||||
val chatUrl: String? = null,
|
||||
val isSubscribed: Boolean = false,
|
||||
val description: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubscribeMaxChannelRequest(val link: String)
|
||||
|
||||
@Serializable
|
||||
data class RegisterPushDeviceRequest(
|
||||
val firebaseToken: String,
|
||||
|
||||
@@ -31,10 +31,12 @@ 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.MaxBridgeStatusDto
|
||||
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
|
||||
import xyz.kusoft.qmax.core.model.MessageDto
|
||||
import xyz.kusoft.qmax.core.model.RegisterPushDeviceRequest
|
||||
import xyz.kusoft.qmax.core.model.SendMessageRequest
|
||||
import xyz.kusoft.qmax.core.model.SetReactionRequest
|
||||
import xyz.kusoft.qmax.core.model.SubscribeMaxChannelRequest
|
||||
import xyz.kusoft.qmax.core.model.SubmitMaxCodeRequest
|
||||
import java.io.IOException
|
||||
import java.io.File
|
||||
@@ -187,6 +189,15 @@ class QMaxApi {
|
||||
return post(sessionServerUrl, "/api/max/login/code", token, SubmitMaxCodeRequest(code))
|
||||
}
|
||||
|
||||
suspend fun searchMaxChannels(sessionServerUrl: String, token: String, query: String): List<MaxChannelSearchResultDto> {
|
||||
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.name())
|
||||
return get(sessionServerUrl, "/api/max/channels/search?q=$encodedQuery", token)
|
||||
}
|
||||
|
||||
suspend fun subscribeMaxChannel(sessionServerUrl: String, token: String, link: String): ChatDto {
|
||||
return post(sessionServerUrl, "/api/max/channels/subscribe", token, SubscribeMaxChannelRequest(link))
|
||||
}
|
||||
|
||||
suspend fun registerPushDevice(sessionServerUrl: String, token: String, firebaseToken: String) {
|
||||
postNoContent(sessionServerUrl, "/api/push/devices", token, RegisterPushDeviceRequest(firebaseToken))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ 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.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.QMaxSession
|
||||
@@ -35,6 +36,9 @@ data class QMaxUiState(
|
||||
val messages: List<MessageDto> = emptyList(),
|
||||
val chatSearchResults: List<MessageDto> = emptyList(),
|
||||
val chatSearchLoading: Boolean = false,
|
||||
val channelSearchResults: List<MaxChannelSearchResultDto> = emptyList(),
|
||||
val channelSearchLoading: Boolean = false,
|
||||
val channelSearchError: String? = null,
|
||||
val chatPresenceText: String? = null,
|
||||
val replyTarget: MessageDto? = null,
|
||||
val editTarget: MessageDto? = null,
|
||||
@@ -72,6 +76,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
||||
private var chatRefreshJob: Job? = null
|
||||
private var chatRefreshGeneration = 0L
|
||||
private var chatSearchJob: Job? = null
|
||||
private var channelSearchJob: Job? = null
|
||||
private var maxStatusPollJob: Job? = null
|
||||
private var autoLoginJob: Job? = null
|
||||
private var pushRegisteredForToken: String? = null
|
||||
@@ -114,6 +119,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
||||
messagePollJob?.cancel()
|
||||
presencePollJob?.cancel()
|
||||
maxStatusPollJob?.cancel()
|
||||
channelSearchJob?.cancel()
|
||||
pushRegistrationJob?.cancel()
|
||||
pushRegistrationInFlightForToken = null
|
||||
pushRegisteredForToken = null
|
||||
@@ -162,6 +168,61 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
||||
state.value = state.value.copy(searchQuery = value)
|
||||
}
|
||||
|
||||
fun searchMaxChannels(query: String) {
|
||||
channelSearchJob?.cancel()
|
||||
val trimmed = query.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
state.value = state.value.copy(
|
||||
channelSearchResults = emptyList(),
|
||||
channelSearchLoading = false,
|
||||
channelSearchError = null
|
||||
)
|
||||
return
|
||||
}
|
||||
state.value = state.value.copy(channelSearchLoading = true, channelSearchError = null)
|
||||
channelSearchJob = viewModelScope.launch {
|
||||
delay(350)
|
||||
val session = state.value.session ?: run {
|
||||
state.value = state.value.copy(channelSearchLoading = false)
|
||||
return@launch
|
||||
}
|
||||
runCatching {
|
||||
repository.searchMaxChannels(session, trimmed)
|
||||
}.onSuccess { results ->
|
||||
state.value = state.value.copy(
|
||||
channelSearchResults = results,
|
||||
channelSearchLoading = false,
|
||||
channelSearchError = null
|
||||
)
|
||||
}.onFailure { error ->
|
||||
if (error is CancellationException) throw error
|
||||
state.value = state.value.copy(
|
||||
channelSearchResults = emptyList(),
|
||||
channelSearchLoading = false,
|
||||
channelSearchError = error.message ?: "Ошибка поиска каналов"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun subscribeMaxChannel(result: MaxChannelSearchResultDto) = launchLoading(false) {
|
||||
val session = requireSession()
|
||||
val link = result.chatUrl?.takeIf { it.isNotBlank() } ?: result.externalId
|
||||
val chat = repository.subscribeMaxChannel(session, link)
|
||||
state.value = state.value.copy(
|
||||
chats = normalizeChats(state.value.chats.filterNot { it.id == chat.id } + chat),
|
||||
channelSearchResults = state.value.channelSearchResults.map {
|
||||
if (it.externalId == result.externalId || it.chatUrl == result.chatUrl) {
|
||||
it.copy(isSubscribed = true)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
channelSearchError = null
|
||||
)
|
||||
loadChats()
|
||||
}
|
||||
|
||||
fun beginChatSelection(chatId: String) {
|
||||
if (chatId.isBlank()) return
|
||||
state.value = state.value.copy(selectedChatIds = state.value.selectedChatIds + chatId)
|
||||
|
||||
Reference in New Issue
Block a user