Add channel search and subscription

This commit is contained in:
sevenhill
2026-07-07 22:09:18 +03:00
parent b2766ebbe9
commit 0a42e6f860
14 changed files with 421 additions and 4 deletions
@@ -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)