fix: improve realtime chat state feedback
fix: separate management errors from global chat-list failures feat: show connecting and typing states in chat subtitle
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -32,4 +32,5 @@ data class ChatListUiState(
|
||||
val globalSearchQuery: String = "",
|
||||
val isManagementLoading: Boolean = false,
|
||||
val managementMessage: String? = null,
|
||||
val managementErrorMessage: String? = null,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user