android: prevent self member actions and add member action confirmations
Some checks failed
Android CI / android (push) Failing after 4m57s
Android Release / release (push) Failing after 5m5s
CI / test (push) Failing after 3m8s

This commit is contained in:
2026-03-11 05:17:57 +03:00
parent 0510a2717a
commit 3bc540e46d
5 changed files with 87 additions and 6 deletions

View File

@@ -1350,6 +1350,7 @@ fun ChatScreen(
}, },
members = state.chatMembers, members = state.chatMembers,
bans = state.chatBans, bans = state.chatBans,
selfUserId = state.selfUserId,
canManageMembers = state.canManageMembers, canManageMembers = state.canManageMembers,
canTransferOwnership = state.chatRole.equals("owner", ignoreCase = true), canTransferOwnership = state.chatRole.equals("owner", ignoreCase = true),
onPromoteMember = onPromoteMember, onPromoteMember = onPromoteMember,
@@ -3467,6 +3468,7 @@ private fun ChatInfoTabContent(
onForceCycleSpeedAudioSourceHandled: (String) -> Unit, onForceCycleSpeedAudioSourceHandled: (String) -> Unit,
members: List<ChatMemberItem>, members: List<ChatMemberItem>,
bans: List<ChatBanItem>, bans: List<ChatBanItem>,
selfUserId: Long?,
canManageMembers: Boolean, canManageMembers: Boolean,
canTransferOwnership: Boolean, canTransferOwnership: Boolean,
onPromoteMember: (Long) -> Unit, onPromoteMember: (Long) -> Unit,
@@ -3481,6 +3483,7 @@ private fun ChatInfoTabContent(
ChatMembersTabContent( ChatMembersTabContent(
members = members, members = members,
bans = bans, bans = bans,
selfUserId = selfUserId,
canManageMembers = canManageMembers, canManageMembers = canManageMembers,
canTransferOwnership = canTransferOwnership, canTransferOwnership = canTransferOwnership,
onPromoteMember = onPromoteMember, onPromoteMember = onPromoteMember,
@@ -3725,6 +3728,7 @@ private val urlRegex = Regex("""https?://[^\s]+""")
private fun ChatMembersTabContent( private fun ChatMembersTabContent(
members: List<ChatMemberItem>, members: List<ChatMemberItem>,
bans: List<ChatBanItem>, bans: List<ChatBanItem>,
selfUserId: Long?,
canManageMembers: Boolean, canManageMembers: Boolean,
canTransferOwnership: Boolean, canTransferOwnership: Boolean,
onPromoteMember: (Long) -> Unit, onPromoteMember: (Long) -> Unit,
@@ -3734,6 +3738,13 @@ private fun ChatMembersTabContent(
onTransferOwnership: (Long) -> Unit, onTransferOwnership: (Long) -> Unit,
onUnbanMember: (Long) -> Unit, onUnbanMember: (Long) -> Unit,
) { ) {
data class PendingMemberAction(
val title: String,
val body: String,
val onConfirm: () -> Unit,
)
var pendingAction by remember { mutableStateOf<PendingMemberAction?>(null) }
if (members.isEmpty() && bans.isEmpty()) { if (members.isEmpty() && bans.isEmpty()) {
Box( Box(
modifier = Modifier 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -3807,22 +3819,46 @@ private fun ChatMembersTabContent(
} }
if (member.role.equals("admin", ignoreCase = true)) { if (member.role.equals("admin", ignoreCase = true)) {
AssistChip( 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") }, label = { Text("Demote") },
) )
} }
if (canTransferOwnership) { if (canTransferOwnership) {
AssistChip( 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") }, label = { Text("Transfer owner") },
) )
} }
AssistChip( 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") }, label = { Text("Ban") },
) )
AssistChip( 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") }, 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<MessageItem>): List<ChatInfoEntry> { private fun buildChatInfoEntries(messages: List<MessageItem>): List<ChatInfoEntry> {

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher 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.repository.ChatRepository
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatUseCase import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatUseCase
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
@@ -64,6 +65,7 @@ class ChatViewModel @Inject constructor(
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
private val activeChatTracker: ActiveChatTracker, private val activeChatTracker: ActiveChatTracker,
private val notificationDispatcher: NotificationDispatcher, private val notificationDispatcher: NotificationDispatcher,
private val tokenRepository: TokenRepository,
) : ViewModel() { ) : ViewModel() {
private val chatId: Long = checkNotNull(savedStateHandle["chatId"]) private val chatId: Long = checkNotNull(savedStateHandle["chatId"])
@@ -506,18 +508,34 @@ class ChatViewModel @Inject constructor(
} }
fun promoteMember(userId: Long) { 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") updateMemberRole(userId = userId, role = "admin")
} }
fun demoteMember(userId: Long) { 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") updateMemberRole(userId = userId, role = "member")
} }
fun transferOwnership(userId: Long) { 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") updateMemberRole(userId = userId, role = "owner")
} }
fun kickMember(userId: Long) { fun kickMember(userId: Long) {
if (userId == uiState.value.selfUserId) {
_uiState.update { it.copy(errorMessage = "You cannot kick yourself.") }
return
}
viewModelScope.launch { viewModelScope.launch {
when (val result = chatRepository.removeMember(chatId = chatId, userId = userId)) { when (val result = chatRepository.removeMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> refreshMembersAndBans() is AppResult.Success -> refreshMembersAndBans()
@@ -527,6 +545,10 @@ class ChatViewModel @Inject constructor(
} }
fun banMember(userId: Long) { fun banMember(userId: Long) {
if (userId == uiState.value.selfUserId) {
_uiState.update { it.copy(errorMessage = "You cannot ban yourself.") }
return
}
viewModelScope.launch { viewModelScope.launch {
when (val result = chatRepository.banMember(chatId = chatId, userId = userId)) { when (val result = chatRepository.banMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> refreshMembersAndBans() is AppResult.Success -> refreshMembersAndBans()
@@ -807,11 +829,13 @@ class ChatViewModel @Inject constructor(
private fun refresh() { private fun refresh() {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
val selfUserId = tokenRepository.getActiveUserId()
when (val result = syncRecentMessagesUseCase(chatId = chatId)) { 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 { is AppResult.Error -> _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
selfUserId = selfUserId,
errorMessage = result.reason.toUiMessage(), errorMessage = result.reason.toUiMessage(),
) )
} }

View File

@@ -7,6 +7,7 @@ import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
data class MessageUiState( data class MessageUiState(
val chatId: Long = 0L, val chatId: Long = 0L,
val selfUserId: Long? = null,
val isLoading: Boolean = true, val isLoading: Boolean = true,
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
val isSending: Boolean = false, val isSending: Boolean = false,

View File

@@ -24,6 +24,7 @@
<string name="toast_night_mode_enabled">Включен ночной режим.</string> <string name="toast_night_mode_enabled">Включен ночной режим.</string>
<string name="common_cancel">Отмена</string> <string name="common_cancel">Отмена</string>
<string name="common_confirm">Подтвердить</string>
<string name="common_close">Закрыть</string> <string name="common_close">Закрыть</string>
<string name="common_delete">Удалить</string> <string name="common_delete">Удалить</string>
<string name="common_send">Отправить</string> <string name="common_send">Отправить</string>

View File

@@ -24,6 +24,7 @@
<string name="chats_not_found">No chats found</string> <string name="chats_not_found">No chats found</string>
<string name="common_cancel">Cancel</string> <string name="common_cancel">Cancel</string>
<string name="common_confirm">Confirm</string>
<string name="common_close">Close</string> <string name="common_close">Close</string>
<string name="common_delete">Delete</string> <string name="common_delete">Delete</string>
<string name="common_send">Send</string> <string name="common_send">Send</string>