From 3bc540e46deb0d348f6dcf5d3d524b82a0cf4f15 Mon Sep 17 00:00:00 2001 From: benya Date: Wed, 11 Mar 2026 05:17:57 +0300 Subject: [PATCH] android: prevent self member actions and add member action confirmations --- .../messenger/ui/chat/ChatScreen.kt | 64 +++++++++++++++++-- .../messenger/ui/chat/ChatViewModel.kt | 26 +++++++- .../messenger/ui/chat/MessageUiState.kt | 1 + .../app/src/main/res/values-ru/strings.xml | 1 + android/app/src/main/res/values/strings.xml | 1 + 5 files changed, 87 insertions(+), 6 deletions(-) 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 7a82d54..15f21a6 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 @@ -1350,6 +1350,7 @@ fun ChatScreen( }, members = state.chatMembers, bans = state.chatBans, + selfUserId = state.selfUserId, canManageMembers = state.canManageMembers, canTransferOwnership = state.chatRole.equals("owner", ignoreCase = true), onPromoteMember = onPromoteMember, @@ -3467,6 +3468,7 @@ private fun ChatInfoTabContent( onForceCycleSpeedAudioSourceHandled: (String) -> Unit, members: List, bans: List, + selfUserId: Long?, canManageMembers: Boolean, canTransferOwnership: Boolean, onPromoteMember: (Long) -> Unit, @@ -3481,6 +3483,7 @@ private fun ChatInfoTabContent( ChatMembersTabContent( members = members, bans = bans, + selfUserId = selfUserId, canManageMembers = canManageMembers, canTransferOwnership = canTransferOwnership, onPromoteMember = onPromoteMember, @@ -3725,6 +3728,7 @@ private val urlRegex = Regex("""https?://[^\s]+""") private fun ChatMembersTabContent( members: List, bans: List, + selfUserId: Long?, canManageMembers: Boolean, canTransferOwnership: Boolean, onPromoteMember: (Long) -> Unit, @@ -3734,6 +3738,13 @@ private fun ChatMembersTabContent( onTransferOwnership: (Long) -> Unit, onUnbanMember: (Long) -> Unit, ) { + data class PendingMemberAction( + val title: String, + val body: String, + val onConfirm: () -> Unit, + ) + var pendingAction by remember { mutableStateOf(null) } + if (members.isEmpty() && bans.isEmpty()) { Box( modifier = Modifier @@ -3792,7 +3803,8 @@ private fun ChatMembersTabContent( } } - if (canManageMembers && !member.role.equals("owner", ignoreCase = true)) { + val isSelf = selfUserId != null && member.userId == selfUserId + if (canManageMembers && !member.role.equals("owner", ignoreCase = true) && !isSelf) { Row( modifier = Modifier .fillMaxWidth() @@ -3807,22 +3819,46 @@ private fun ChatMembersTabContent( } if (member.role.equals("admin", ignoreCase = true)) { AssistChip( - onClick = { onDemoteMember(member.userId) }, + onClick = { + pendingAction = PendingMemberAction( + title = "Demote admin", + body = "Demote ${member.name.ifBlank { "@${member.username ?: member.userId}" }} to member?", + onConfirm = { onDemoteMember(member.userId) }, + ) + }, label = { Text("Demote") }, ) } if (canTransferOwnership) { AssistChip( - onClick = { onTransferOwnership(member.userId) }, + onClick = { + pendingAction = PendingMemberAction( + title = "Transfer ownership", + body = "Transfer ownership to ${member.name.ifBlank { "@${member.username ?: member.userId}" }}?", + onConfirm = { onTransferOwnership(member.userId) }, + ) + }, label = { Text("Transfer owner") }, ) } AssistChip( - onClick = { onBanMember(member.userId) }, + onClick = { + pendingAction = PendingMemberAction( + title = "Ban member", + body = "Ban ${member.name.ifBlank { "@${member.username ?: member.userId}" }}?", + onConfirm = { onBanMember(member.userId) }, + ) + }, label = { Text("Ban") }, ) AssistChip( - onClick = { onKickMember(member.userId) }, + onClick = { + pendingAction = PendingMemberAction( + title = "Kick member", + body = "Kick ${member.name.ifBlank { "@${member.username ?: member.userId}" }} from chat?", + onConfirm = { onKickMember(member.userId) }, + ) + }, label = { Text("Kick") }, ) } @@ -3871,6 +3907,24 @@ private fun ChatMembersTabContent( } } } + pendingAction?.let { action -> + AlertDialog( + onDismissRequest = { pendingAction = null }, + title = { Text(action.title) }, + text = { Text(action.body) }, + confirmButton = { + TextButton( + onClick = { + action.onConfirm() + pendingAction = null + }, + ) { Text(stringResource(id = R.string.common_confirm)) } + }, + dismissButton = { + TextButton(onClick = { pendingAction = null }) { Text(stringResource(id = R.string.common_cancel)) } + }, + ) + } } private fun buildChatInfoEntries(messages: List): List { 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 40be56a..2a26ac9 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 @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch 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.domain.chat.repository.ChatRepository import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatUseCase import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase @@ -64,6 +65,7 @@ class ChatViewModel @Inject constructor( private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, private val activeChatTracker: ActiveChatTracker, private val notificationDispatcher: NotificationDispatcher, + private val tokenRepository: TokenRepository, ) : ViewModel() { private val chatId: Long = checkNotNull(savedStateHandle["chatId"]) @@ -506,18 +508,34 @@ class ChatViewModel @Inject constructor( } fun promoteMember(userId: Long) { + if (userId == uiState.value.selfUserId) { + _uiState.update { it.copy(errorMessage = "You cannot change your own role from this screen.") } + return + } updateMemberRole(userId = userId, role = "admin") } fun demoteMember(userId: Long) { + if (userId == uiState.value.selfUserId) { + _uiState.update { it.copy(errorMessage = "You cannot change your own role from this screen.") } + return + } updateMemberRole(userId = userId, role = "member") } fun transferOwnership(userId: Long) { + if (userId == uiState.value.selfUserId) { + _uiState.update { it.copy(errorMessage = "Transfer ownership to another member.") } + return + } updateMemberRole(userId = userId, role = "owner") } fun kickMember(userId: Long) { + if (userId == uiState.value.selfUserId) { + _uiState.update { it.copy(errorMessage = "You cannot kick yourself.") } + return + } viewModelScope.launch { when (val result = chatRepository.removeMember(chatId = chatId, userId = userId)) { is AppResult.Success -> refreshMembersAndBans() @@ -527,6 +545,10 @@ class ChatViewModel @Inject constructor( } fun banMember(userId: Long) { + if (userId == uiState.value.selfUserId) { + _uiState.update { it.copy(errorMessage = "You cannot ban yourself.") } + return + } viewModelScope.launch { when (val result = chatRepository.banMember(chatId = chatId, userId = userId)) { is AppResult.Success -> refreshMembersAndBans() @@ -807,11 +829,13 @@ class ChatViewModel @Inject constructor( private fun refresh() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, errorMessage = null) } + val selfUserId = tokenRepository.getActiveUserId() when (val result = syncRecentMessagesUseCase(chatId = chatId)) { - is AppResult.Success -> _uiState.update { it.copy(isLoading = false) } + is AppResult.Success -> _uiState.update { it.copy(isLoading = false, selfUserId = selfUserId) } is AppResult.Error -> _uiState.update { it.copy( isLoading = false, + selfUserId = selfUserId, errorMessage = result.reason.toUiMessage(), ) } 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 24c8ea9..b2d6524 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 @@ -7,6 +7,7 @@ import ru.daemonlord.messenger.domain.chat.model.ChatBanItem data class MessageUiState( val chatId: Long = 0L, + val selfUserId: Long? = null, val isLoading: Boolean = true, val isLoadingMore: Boolean = false, val isSending: 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 362d282..ed38280 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -24,6 +24,7 @@ Включен ночной режим. Отмена + Подтвердить Закрыть Удалить Отправить diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e88aa43..e401776 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ No chats found Cancel + Confirm Close Delete Send