fix: improve realtime chat state feedback
Some checks failed
Android CI / android (push) Has been cancelled
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

fix: separate management errors from global chat-list failures

feat: show connecting and typing states in chat subtitle
This commit is contained in:
2026-04-05 14:54:10 +03:00
parent e8f9efb108
commit ee5df806c1
7 changed files with 109 additions and 5 deletions

View File

@@ -14,6 +14,8 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
@@ -39,6 +41,9 @@ import ru.daemonlord.messenger.domain.message.usecase.SendMediaMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase
import ru.daemonlord.messenger.domain.message.usecase.ToggleMessageReactionUseCase
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import java.time.Instant
import java.time.temporal.ChronoUnit
@@ -66,6 +71,7 @@ class ChatViewModel @Inject constructor(
private val observeChatUseCase: ObserveChatUseCase,
private val observeChatsUseCase: ObserveChatsUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
private val realtimeManager: RealtimeManager,
private val activeChatTracker: ActiveChatTracker,
private val notificationDispatcher: NotificationDispatcher,
private val tokenRepository: TokenRepository,
@@ -80,12 +86,14 @@ class ChatViewModel @Inject constructor(
private var lastReadMessageId: Long? = null
private val reactionsRequestedMessageIds = mutableSetOf<Long>()
private var membersLoadKey: String? = null
private var typingResetJob: Job? = null
init {
activeChatTracker.setActiveChat(chatId)
notificationDispatcher.clearChatNotifications(chatId)
handleRealtimeEventsUseCase.start()
observeChatPermissions()
observeRealtimeState()
observeMessages()
refresh()
}
@@ -769,7 +777,12 @@ class ChatViewModel @Inject constructor(
chatRole = role,
chatMuted = chat.muted,
chatTitle = chatTitle,
chatSubtitle = chatSubtitle,
baseChatSubtitle = chatSubtitle,
chatSubtitle = resolveDisplayedSubtitle(
baseSubtitle = chatSubtitle,
isRealtimeConnecting = it.isRealtimeConnecting,
isTyping = it.isTyping,
),
chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl,
chatUnreadCount = chat.unreadCount.coerceAtLeast(0),
canManageMembers = role == "owner" || role == "admin",
@@ -791,6 +804,87 @@ class ChatViewModel @Inject constructor(
}
}
private fun observeRealtimeState() {
viewModelScope.launch {
realtimeManager.connectionState.collectLatest { connectionState ->
_uiState.update {
val isConnecting = connectionState == RealtimeConnectionState.Connecting ||
connectionState == RealtimeConnectionState.Reconnecting
it.copy(
isRealtimeConnecting = isConnecting,
chatSubtitle = resolveDisplayedSubtitle(
baseSubtitle = it.baseChatSubtitle,
isRealtimeConnecting = isConnecting,
isTyping = it.isTyping,
),
)
}
}
}
viewModelScope.launch {
realtimeManager.events.collectLatest { event ->
when (event) {
is RealtimeEvent.TypingStart -> {
if (event.chatId != chatId) return@collectLatest
typingResetJob?.cancel()
_uiState.update {
it.copy(
isTyping = true,
chatSubtitle = resolveDisplayedSubtitle(
baseSubtitle = it.baseChatSubtitle,
isRealtimeConnecting = it.isRealtimeConnecting,
isTyping = true,
),
)
}
typingResetJob = viewModelScope.launch {
delay(5_000L)
_uiState.update {
it.copy(
isTyping = false,
chatSubtitle = resolveDisplayedSubtitle(
baseSubtitle = it.baseChatSubtitle,
isRealtimeConnecting = it.isRealtimeConnecting,
isTyping = false,
),
)
}
}
}
is RealtimeEvent.TypingStop -> {
if (event.chatId != chatId) return@collectLatest
typingResetJob?.cancel()
_uiState.update {
it.copy(
isTyping = false,
chatSubtitle = resolveDisplayedSubtitle(
baseSubtitle = it.baseChatSubtitle,
isRealtimeConnecting = it.isRealtimeConnecting,
isTyping = false,
),
)
}
}
else -> Unit
}
}
}
}
private fun resolveDisplayedSubtitle(
baseSubtitle: String,
isRealtimeConnecting: Boolean,
isTyping: Boolean,
): String {
return when {
isTyping -> context.getString(R.string.chat_status_typing)
isRealtimeConnecting -> context.getString(R.string.chats_connecting)
else -> baseSubtitle
}
}
private fun updateMemberRole(userId: Long, role: String) {
viewModelScope.launch {
when (val result = chatRepository.updateMemberRole(chatId = chatId, userId = userId, role = role)) {

View File

@@ -14,6 +14,7 @@ data class MessageUiState(
val isUploadingMedia: Boolean = false,
val errorMessage: String? = null,
val chatTitle: String = "",
val baseChatSubtitle: String = "",
val chatSubtitle: String = "",
val chatAvatarUrl: String? = null,
val chatType: String = "",
@@ -40,6 +41,8 @@ data class MessageUiState(
val sendRestrictionText: String? = null,
val isRecordingVoice: Boolean = false,
val isVoiceLocked: Boolean = false,
val isRealtimeConnecting: Boolean = false,
val isTyping: Boolean = false,
val voiceRecordingDurationMs: Long = 0L,
val voiceRecordingHint: String? = null,
val inlineSearchQuery: String = "",

View File

@@ -772,6 +772,9 @@ fun ChatListScreen(
if (!state.managementMessage.isNullOrBlank()) {
Text(state.managementMessage, color = MaterialTheme.colorScheme.primary)
}
if (!state.managementErrorMessage.isNullOrBlank()) {
Text(state.managementErrorMessage, color = MaterialTheme.colorScheme.error)
}
if (state.members.isNotEmpty()) {
Text("Members:", fontWeight = FontWeight.SemiBold)
state.members.take(6).forEach { member ->

View File

@@ -32,4 +32,5 @@ data class ChatListUiState(
val globalSearchQuery: String = "",
val isManagementLoading: Boolean = false,
val managementMessage: String? = null,
val managementErrorMessage: String? = null,
)

View File

@@ -210,18 +210,19 @@ class ChatListViewModel @Inject constructor(
fun discoverChats(query: String?) {
viewModelScope.launch {
_uiState.update { it.copy(isManagementLoading = true, errorMessage = null) }
_uiState.update { it.copy(isManagementLoading = true, managementErrorMessage = null) }
when (val result = chatRepository.discoverChats(query = query?.trim()?.ifBlank { null })) {
is AppResult.Success -> _uiState.update {
it.copy(
isManagementLoading = false,
discoverChats = result.data,
managementErrorMessage = null,
)
}
is AppResult.Error -> _uiState.update {
it.copy(
isManagementLoading = false,
errorMessage = result.reason.toUiMessage(),
managementErrorMessage = result.reason.toUiMessage(),
)
}
}
@@ -617,7 +618,7 @@ class ChatListViewModel @Inject constructor(
private fun loadMembersAndBans(chatId: Long) {
viewModelScope.launch {
_uiState.update { it.copy(isManagementLoading = true, errorMessage = null) }
_uiState.update { it.copy(isManagementLoading = true, managementErrorMessage = null) }
val membersResult = chatRepository.listMembers(chatId = chatId)
val bansResult = chatRepository.listBans(chatId = chatId)
_uiState.update {
@@ -625,7 +626,7 @@ class ChatListViewModel @Inject constructor(
isManagementLoading = false,
members = (membersResult as? AppResult.Success)?.data ?: emptyList(),
bans = (bansResult as? AppResult.Success)?.data ?: emptyList(),
errorMessage = listOf(membersResult, bansResult)
managementErrorMessage = listOf(membersResult, bansResult)
.filterIsInstance<AppResult.Error>()
.firstOrNull()
?.reason

View File

@@ -135,6 +135,7 @@
<string name="chat_open_item_failed">Не удалось открыть элемент</string>
<string name="chat_status_online">в сети</string>
<string name="chat_status_last_seen_recently">был(а) недавно</string>
<string name="chat_status_typing">печатает…</string>
<string name="chat_type_group">группа</string>
<string name="chat_type_channel">канал</string>
<string name="chat_info_tab_media">Медиа</string>

View File

@@ -135,6 +135,7 @@
<string name="chat_open_item_failed">Unable to open item</string>
<string name="chat_status_online">online</string>
<string name="chat_status_last_seen_recently">last seen recently</string>
<string name="chat_status_typing">typing…</string>
<string name="chat_type_group">group</string>
<string name="chat_type_channel">channel</string>
<string name="chat_info_tab_media">Media</string>