From 2a6aa7e8ad6a70639dd6fd1015fb90fb9a585216 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 5 Apr 2026 15:13:48 +0300 Subject: [PATCH] feat: add unknown private chat actions banner feat: show add-contact and block actions for unknown users in private chats refactor: expose counterpart user ids through chat domain models --- .../messenger/data/chat/local/dao/ChatDao.kt | 2 + .../chat/local/model/ChatListLocalModel.kt | 2 + .../data/chat/mapper/ChatLocalMapper.kt | 2 + .../messenger/domain/chat/model/ChatItem.kt | 1 + .../messenger/ui/chat/ChatScreen.kt | 58 +++++++++++++ .../messenger/ui/chat/ChatViewModel.kt | 85 +++++++++++++++++++ .../messenger/ui/chat/MessageUiState.kt | 3 + .../app/src/main/res/values-ru/strings.xml | 4 + android/app/src/main/res/values/strings.xml | 4 + 9 files changed, 161 insertions(+) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt index c10f9a7..96f1284 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt @@ -28,6 +28,7 @@ interface ChatDao { c.muted, c.unread_count, c.unread_mentions_count, + c.counterpart_user_id, COALESCE(c.counterpart_name, u.display_name) AS counterpart_name, COALESCE(c.counterpart_username, u.username) AS counterpart_username, COALESCE(c.counterpart_avatar_url, u.avatar_url) AS counterpart_avatar_url, @@ -62,6 +63,7 @@ interface ChatDao { c.muted, c.unread_count, c.unread_mentions_count, + c.counterpart_user_id, COALESCE(c.counterpart_name, u.display_name) AS counterpart_name, COALESCE(c.counterpart_username, u.username) AS counterpart_username, COALESCE(c.counterpart_avatar_url, u.avatar_url) AS counterpart_avatar_url, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt index 5c85cf3..107e72c 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/model/ChatListLocalModel.kt @@ -27,6 +27,8 @@ data class ChatListLocalModel( val unreadCount: Int, @ColumnInfo(name = "unread_mentions_count") val unreadMentionsCount: Int, + @ColumnInfo(name = "counterpart_user_id") + val counterpartUserId: Long?, @ColumnInfo(name = "counterpart_name") val counterpartName: String?, @ColumnInfo(name = "counterpart_username") diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt index 8b7dc57..0459092 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/mapper/ChatLocalMapper.kt @@ -18,6 +18,7 @@ fun ChatListLocalModel.toDomain(): ChatItem { muted = muted, unreadCount = unreadCount, unreadMentionsCount = unreadMentionsCount, + counterpartUserId = counterpartUserId, counterpartUsername = counterpartUsername, counterpartName = counterpartName, counterpartAvatarUrl = counterpartAvatarUrl, @@ -46,6 +47,7 @@ fun ChatEntity.toDomain(): ChatItem { muted = muted, unreadCount = unreadCount, unreadMentionsCount = unreadMentionsCount, + counterpartUserId = counterpartUserId, counterpartUsername = counterpartUsername, counterpartName = counterpartName, counterpartAvatarUrl = counterpartAvatarUrl, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatItem.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatItem.kt index 9e2a03d..d241c3d 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatItem.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/model/ChatItem.kt @@ -13,6 +13,7 @@ data class ChatItem( val muted: Boolean, val unreadCount: Int, val unreadMentionsCount: Int, + val counterpartUserId: Long?, val counterpartUsername: String?, val counterpartName: String?, val counterpartAvatarUrl: String?, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index bd349b2..d90f300 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -57,8 +57,10 @@ import androidx.compose.material3.AssistChip import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Slider import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.DropdownMenu @@ -438,6 +440,8 @@ fun ChatRoute( onToggleChatNotifications = viewModel::onToggleChatNotifications, onClearHistory = viewModel::onClearHistory, onDeleteChat = viewModel::onDeleteOrLeaveChat, + onAddCounterpartToContacts = viewModel::onAddCounterpartToContacts, + onBlockCounterpart = viewModel::onBlockCounterpart, onPromoteMember = viewModel::promoteMember, onDemoteMember = viewModel::demoteMember, onBanMember = viewModel::banMember, @@ -526,6 +530,8 @@ private data class ChatScreenActions( val onToggleChatNotifications: () -> Unit, val onClearHistory: () -> Unit, val onDeleteChat: () -> Unit, + val onAddCounterpartToContacts: () -> Unit, + val onBlockCounterpart: () -> Unit, val onPromoteMember: (Long) -> Unit, val onDemoteMember: (Long) -> Unit, val onBanMember: (Long) -> Unit, @@ -578,6 +584,8 @@ private fun ChatScreen( val onToggleChatNotifications = actions.onToggleChatNotifications val onClearHistory = actions.onClearHistory val onDeleteChat = actions.onDeleteChat + val onAddCounterpartToContacts = actions.onAddCounterpartToContacts + val onBlockCounterpart = actions.onBlockCounterpart val onPromoteMember = actions.onPromoteMember val onDemoteMember = actions.onDemoteMember val onBanMember = actions.onBanMember @@ -591,6 +599,10 @@ private fun ChatScreen( val allViewerMediaItems = remember(state.messages) { buildChatViewerMediaItems(state.messages) } val isChannelChat = state.chatType.equals("channel", ignoreCase = true) val isPrivateChat = state.chatType.equals("private", ignoreCase = true) + val showUnknownPrivateChatBanner = isPrivateChat && + state.counterpartUserId != null && + !state.isCounterpartContact && + !state.isCounterpartBlocked val canShowMembersTab = state.chatType.equals("group", ignoreCase = true) || (state.chatType.equals("channel", ignoreCase = true) && state.canManageMembers) val timelineItems = remember(state.messages, context) { buildChatTimelineItems(state.messages, context) } @@ -1087,6 +1099,52 @@ private fun ChatScreen( } } } + AnimatedVisibility( + visible = showUnknownPrivateChatBanner, + enter = fadeIn(), + exit = fadeOut(), + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f), + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringResource(id = R.string.chat_unknown_user_banner_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(id = R.string.chat_unknown_user_banner_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilledTonalButton( + onClick = onAddCounterpartToContacts, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(id = R.string.chat_unknown_user_add_contact)) + } + OutlinedButton( + onClick = onBlockCounterpart, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(id = R.string.chat_unknown_user_block)) + } + } + } + } + } val strip = topAudioStrip if (strip != null) { Row( 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 4129dce..ce869f5 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 @@ -21,6 +21,7 @@ import ru.daemonlord.messenger.core.notifications.ActiveChatTracker import ru.daemonlord.messenger.core.notifications.NotificationDispatcher import ru.daemonlord.messenger.core.token.TokenRepository import ru.daemonlord.messenger.R +import ru.daemonlord.messenger.domain.account.repository.AccountRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatUseCase import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase @@ -68,6 +69,7 @@ class ChatViewModel @Inject constructor( private val listMessageReactionsUseCase: ListMessageReactionsUseCase, private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase, private val chatRepository: ChatRepository, + private val accountRepository: AccountRepository, private val observeChatUseCase: ObserveChatUseCase, private val observeChatsUseCase: ObserveChatsUseCase, private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, @@ -86,6 +88,7 @@ class ChatViewModel @Inject constructor( private var lastReadMessageId: Long? = null private val reactionsRequestedMessageIds = mutableSetOf() private var membersLoadKey: String? = null + private var privateChatRelationKey: Long? = null private var typingResetJob: Job? = null init { @@ -774,6 +777,7 @@ class ChatViewModel @Inject constructor( } it.copy( chatType = chat.type, + counterpartUserId = chat.counterpartUserId, chatRole = role, chatMuted = chat.muted, chatTitle = chatTitle, @@ -800,6 +804,56 @@ class ChatViewModel @Inject constructor( membersLoadKey = nextLoadKey refreshMembersAndBans() } + refreshPrivateChatRelationship( + chatType = chat.type, + counterpartUserId = chat.counterpartUserId, + ) + } + } + } + + fun onAddCounterpartToContacts() { + val userId = uiState.value.counterpartUserId ?: return + viewModelScope.launch { + when (val result = accountRepository.addContact(userId = userId)) { + is AppResult.Success -> { + _uiState.update { + it.copy( + isCounterpartContact = true, + isCounterpartBlocked = false, + errorMessage = null, + ) + } + refreshPrivateChatRelationship( + chatType = uiState.value.chatType, + counterpartUserId = userId, + force = true, + ) + } + is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) } + } + } + } + + fun onBlockCounterpart() { + val userId = uiState.value.counterpartUserId ?: return + viewModelScope.launch { + when (val result = accountRepository.blockUser(userId = userId)) { + is AppResult.Success -> { + _uiState.update { + it.copy( + isCounterpartContact = false, + isCounterpartBlocked = true, + errorMessage = null, + ) + } + refreshPrivateChatRelationship( + chatType = uiState.value.chatType, + counterpartUserId = userId, + force = true, + ) + } + is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) } } } } @@ -901,6 +955,37 @@ class ChatViewModel @Inject constructor( } } + private fun refreshPrivateChatRelationship( + chatType: String, + counterpartUserId: Long?, + force: Boolean = false, + ) { + if (!chatType.equals("private", ignoreCase = true) || counterpartUserId == null) { + privateChatRelationKey = null + _uiState.update { + it.copy( + isCounterpartContact = false, + isCounterpartBlocked = false, + ) + } + return + } + if (!force && privateChatRelationKey == counterpartUserId) return + privateChatRelationKey = counterpartUserId + viewModelScope.launch { + val contactsResult = accountRepository.listContacts() + val blockedResult = accountRepository.listBlockedUsers() + val isContact = (contactsResult as? AppResult.Success)?.data?.any { it.id == counterpartUserId } == true + val isBlocked = (blockedResult as? AppResult.Success)?.data?.any { it.id == counterpartUserId } == true + _uiState.update { + it.copy( + isCounterpartContact = isContact, + isCounterpartBlocked = isBlocked, + ) + } + } + } + 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 585cfdd..a1c82be 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 @@ -18,9 +18,12 @@ data class MessageUiState( val chatSubtitle: String = "", val chatAvatarUrl: String? = null, val chatType: String = "", + val counterpartUserId: Long? = null, val chatRole: String? = null, val chatMuted: Boolean = false, val chatUnreadCount: Int = 0, + val isCounterpartContact: Boolean = false, + val isCounterpartBlocked: Boolean = false, val chatMembers: List = emptyList(), val chatBans: List = emptyList(), val canManageMembers: Boolean = false, diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 88eba71..80eb6ec 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -136,6 +136,10 @@ в сети был(а) недавно печатает… + Незнакомый пользователь + Можно добавить этого человека в контакты или заблокировать прямо из чата. + Добавить контакт + Заблокировать группа канал Медиа diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 7a99101..8bf4774 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -136,6 +136,10 @@ online last seen recently typing… + Unknown user + You can add this person to contacts or block them right from the chat. + Add contact + Block user group channel Media