diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index aff5aab..ebd806c 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -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() 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)) { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt index 7e21b08..585cfdd 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt @@ -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 = "", diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt index 69e0e4d..cbbc7f6 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt @@ -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 -> diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt index 1c7d2a4..9f542e8 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt @@ -32,4 +32,5 @@ data class ChatListUiState( val globalSearchQuery: String = "", val isManagementLoading: Boolean = false, val managementMessage: String? = null, + val managementErrorMessage: String? = null, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt index 48352fe..8d2b305 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt @@ -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() .firstOrNull() ?.reason diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index b12d9ad..88eba71 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -135,6 +135,7 @@ Не удалось открыть элемент в сети был(а) недавно + печатает… группа канал Медиа diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e3d0375..7a99101 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -135,6 +135,7 @@ Unable to open item online last seen recently + typing… group channel Media