From 61d710579a842eb9101e44a9cd8984669007ea71 Mon Sep 17 00:00:00 2001 From: Sergei Bakhtiarov Date: Tue, 12 May 2026 16:01:30 +0200 Subject: [PATCH 1/2] feat: UI for promoting next user to admin role (WPB-25278) --- .../di/accountScoped/ConversationModule.kt | 8 + .../ConversationOptionsMenuViewModel.kt | 13 +- .../ConversationOptionsModalSheetLayout.kt | 14 ++ .../details/GroupConversationDetailsScreen.kt | 7 + .../LeaveConversationAdminOptionsDialog.kt | 67 ++++++ .../promoteadmin/PromoteAdminNavArgs.kt | 28 +++ .../promoteadmin/PromoteAdminScreen.kt | 200 ++++++++++++++++++ .../promoteadmin/PromoteAdminViewModel.kt | 117 ++++++++++ .../ConversationsScreenContent.kt | 3 + .../model/GroupDialogState.kt | 6 + app/src/main/res/values/strings.xml | 7 + .../promoteadmin/PromoteAdminViewModelTest.kt | 191 +++++++++++++++++ kalium | 2 +- 13 files changed, 660 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/LeaveConversationAdminOptionsDialog.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminNavArgs.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminScreen.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModel.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModelTest.kt diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt index 58690696b0f..bf6419fe4c7 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt @@ -36,6 +36,7 @@ import com.wire.kalium.logic.feature.conversation.IsOneToOneConversationCreatedU import com.wire.kalium.logic.feature.conversation.JoinConversationViaCodeUseCase import com.wire.kalium.logic.feature.conversation.CheckConversationLeaveConditionsUseCase import com.wire.kalium.logic.feature.conversation.LeaveConversationUseCase +import com.wire.kalium.logic.feature.conversation.ObserveEligibleMembersForConversationAdminRoleUseCase import com.wire.kalium.logic.feature.conversation.NotifyConversationIsOpenUseCase import com.wire.kalium.logic.feature.conversation.ObserveArchivedUnreadConversationsCountUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase @@ -209,6 +210,13 @@ class ConversationModule { fun provideCheckConversationLeaveConditionsUseCase(conversationScope: ConversationScope): CheckConversationLeaveConditionsUseCase = conversationScope.checkConversationLeaveConditions + @ViewModelScoped + @Provides + fun provideObserveEligibleMembersForConversationAdminRoleUseCase( + conversationScope: ConversationScope + ): ObserveEligibleMembersForConversationAdminRoleUseCase = + conversationScope.observeEligibleMembersForConversationAdminRole + @ViewModelScoped @Provides fun provideUpdateConversationMutedStatusUseCase(conversationScope: ConversationScope): UpdateConversationMutedStatusUseCase = diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsMenuViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsMenuViewModel.kt index 04fdb3811b4..c72e6f7f011 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsMenuViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsMenuViewModel.kt @@ -35,6 +35,7 @@ import com.wire.android.ui.home.HomeSnackBarMessage import com.wire.android.ui.home.conversationslist.model.DeleteGroupDialogState import com.wire.android.ui.home.conversationslist.model.DialogState import com.wire.android.ui.home.conversationslist.model.LeaveGroupDialogState +import com.wire.android.ui.home.conversationslist.model.LeaveGroupOptionsDialogState import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.workmanager.worker.enqueueConversationDeletionLocally import com.wire.kalium.logic.data.conversation.ConversationFolder @@ -46,9 +47,9 @@ import com.wire.kalium.logic.feature.connection.BlockUserUseCase import com.wire.kalium.logic.feature.connection.UnblockUserResult import com.wire.kalium.logic.feature.connection.UnblockUserUseCase import com.wire.kalium.logic.feature.conversation.ArchiveStatusUpdateResult +import com.wire.kalium.logic.feature.conversation.CheckConversationLeaveConditionsUseCase import com.wire.kalium.logic.feature.conversation.ClearConversationContentUseCase import com.wire.kalium.logic.feature.conversation.ConversationUpdateStatusResult -import com.wire.kalium.logic.feature.conversation.CheckConversationLeaveConditionsUseCase import com.wire.kalium.logic.feature.conversation.LeaveConversationUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.feature.conversation.RemoveMemberFromConversationUseCase @@ -82,6 +83,7 @@ import javax.inject.Inject @ViewModelScopedPreview interface ConversationOptionsMenuViewModel : ActionsManager { val leaveGroupDialogState: VisibilityState get() = VisibilityState() + val leaveGroupOptionsDialogState: VisibilityState get() = VisibilityState() val deleteGroupDialogState: VisibilityState get() = VisibilityState() val deleteGroupLocallyDialogState: VisibilityState get() = VisibilityState() val blockUserDialogState: VisibilityState get() = VisibilityState() @@ -129,6 +131,7 @@ class ConversationOptionsMenuViewModelImpl @Inject constructor( private val nonCancellableIOContext = NonCancellable + dispatchers.io() private val conversationStateFlow: ConcurrentHashMap> = ConcurrentHashMap() override val leaveGroupDialogState: VisibilityState by mutableStateOf(VisibilityState()) + override val leaveGroupOptionsDialogState: VisibilityState by mutableStateOf(VisibilityState()) override val deleteGroupDialogState: VisibilityState by mutableStateOf(VisibilityState()) override val deleteGroupLocallyDialogState: VisibilityState by mutableStateOf(VisibilityState()) override val blockUserDialogState: VisibilityState by mutableStateOf(VisibilityState()) @@ -253,7 +256,13 @@ class ConversationOptionsMenuViewModelImpl @Inject constructor( when (val result = checkConversationLeaveConditions(leaveGroupState.conversationId)) { CheckConversationLeaveConditionsUseCase.Result.Allow -> leaveGroupDialogState.show(leaveGroupState) is CheckConversationLeaveConditionsUseCase.Result.DoNotAllow -> { - appLogger.i("TODO: Show new leave options dialog: $result") + leaveGroupOptionsDialogState.show( + LeaveGroupOptionsDialogState( + conversationId = leaveGroupState.conversationId, + conversationName = leaveGroupState.conversationName, + showPromoteOption = result.eligibleUsersAvailable + ) + ) } is CheckConversationLeaveConditionsUseCase.Result.Error -> { onMessage(HomeSnackBarMessage.LeaveConversationError) diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsModalSheetLayout.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsModalSheetLayout.kt index 40fe2f2ea4e..8435f34b163 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsModalSheetLayout.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsModalSheetLayout.kt @@ -39,8 +39,10 @@ import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.home.conversations.details.dialog.ClearConversationContentDialog import com.wire.android.ui.home.conversations.details.menu.DeleteConversationGroupDialog import com.wire.android.ui.home.conversations.details.menu.DeleteConversationGroupLocallyDialog +import com.wire.android.ui.home.conversations.details.menu.LeaveConversationAdminOptionsDialog import com.wire.android.ui.home.conversations.details.menu.LeaveConversationGroupDialog import com.wire.android.ui.home.conversations.folder.ConversationFoldersNavArgs +import com.wire.android.ui.home.conversationslist.model.DeleteGroupDialogState import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.id.ConversationId @@ -53,6 +55,7 @@ fun ConversationOptionsModalSheetLayout( onLeftConversation: () -> Unit = {}, onDeletedConversation: () -> Unit = {}, onDeletedConversationLocally: () -> Unit = {}, + onPromoteAdmin: (ConversationId) -> Unit = {}, openConversationDebugMenu: (ConversationId) -> Unit = {}, viewModel: ConversationOptionsMenuViewModel = hiltViewModelScoped() @@ -63,6 +66,17 @@ fun ConversationOptionsModalSheetLayout( dialogState = viewModel.leaveGroupDialogState, onLeaveGroup = { viewModel.leaveGroup(it.conversationId, it.conversationName, it.shouldDelete) } ) + LeaveConversationAdminOptionsDialog( + dialogState = viewModel.leaveGroupOptionsDialogState, + onPromoteAdmin = { state -> + viewModel.leaveGroupOptionsDialogState.dismiss() + onPromoteAdmin(state.conversationId) + }, + onDeleteGroup = { state -> + viewModel.leaveGroupOptionsDialogState.dismiss() + viewModel.deleteGroupDialogState.show(DeleteGroupDialogState(state.conversationId, state.conversationName)) + }, + ) DeleteConversationGroupDialog( dialogState = viewModel.deleteGroupDialogState, onDeleteGroup = { viewModel.deleteGroup(it.conversationId, it.conversationName) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt index 31502f9f631..53ec0b6b3b3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt @@ -104,7 +104,9 @@ import com.ramcosta.composedestinations.generated.app.destinations.SearchConvers import com.ramcosta.composedestinations.generated.app.destinations.SelfUserProfileScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.ServiceDetailsScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.UpdateAppsAccessScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.PromoteAdminScreenDestination import com.wire.android.ui.home.conversations.details.editguestaccess.EditGuestAccessParams +import com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminNavArgs import com.wire.android.ui.home.conversations.details.options.GroupConversationOptions import com.wire.android.ui.home.conversations.details.options.GroupConversationOptionsState import com.wire.android.ui.home.conversations.details.options.LoadingGroupConversation @@ -299,6 +301,9 @@ fun GroupConversationDetailsScreen( ) ) }, + onPromoteAdmin = { conversationId -> + navigator.navigate(NavigationCommand(PromoteAdminScreenDestination(PromoteAdminNavArgs(conversationId)))) + }, openConversationDebugMenu = { navigator.navigate( NavigationCommand( @@ -374,6 +379,7 @@ private fun GroupConversationDetailsContent( onMoveToFolder: (ConversationFoldersNavArgs) -> Unit = {}, onLeftConversation: () -> Unit = {}, onDeletedConversation: () -> Unit = {}, + onPromoteAdmin: (ConversationId) -> Unit = {}, openConversationDebugMenu: (ConversationId) -> Unit = {}, initialPageIndex: GroupConversationDetailsTabItem = GroupConversationDetailsTabItem.OPTIONS, isScreenLoading: StateFlow = MutableStateFlow(false), @@ -559,6 +565,7 @@ private fun GroupConversationDetailsContent( openConversationFolders = onMoveToFolder, onLeftConversation = onLeftConversation, onDeletedConversation = onDeletedConversation, + onPromoteAdmin = onPromoteAdmin, openConversationDebugMenu = openConversationDebugMenu, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/LeaveConversationAdminOptionsDialog.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/LeaveConversationAdminOptionsDialog.kt new file mode 100644 index 00000000000..2c313cb95a8 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/LeaveConversationAdminOptionsDialog.kt @@ -0,0 +1,67 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.ui.home.conversations.details.menu + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.ui.common.VisibilityState +import com.wire.android.ui.common.WireDialog +import com.wire.android.ui.common.WireDialogButtonProperties +import com.wire.android.ui.common.WireDialogButtonType +import com.wire.android.ui.common.visbility.VisibilityState +import com.wire.android.ui.home.conversationslist.model.LeaveGroupOptionsDialogState + +@Composable +internal fun LeaveConversationAdminOptionsDialog( + dialogState: VisibilityState, + onPromoteAdmin: (LeaveGroupOptionsDialogState) -> Unit, + onDeleteGroup: (LeaveGroupOptionsDialogState) -> Unit, +) { + VisibilityState(dialogState) { state -> + WireDialog( + title = stringResource(id = R.string.leave_conversation_dialog_title, state.conversationName), + text = if (state.showPromoteOption) { + stringResource(id = R.string.leave_conversation_admin_options_dialog_description_with_promote) + } else { + stringResource(id = R.string.leave_conversation_admin_options_dialog_description_no_promote) + }, + buttonsHorizontalAlignment = false, + onDismiss = dialogState::dismiss, + optionButton1Properties = if (state.showPromoteOption) { + WireDialogButtonProperties( + onClick = { onPromoteAdmin(state) }, + text = stringResource(id = R.string.leave_conversation_admin_options_dialog_promote_button), + type = WireDialogButtonType.Primary, + ) + } else { + null + }, + optionButton2Properties = WireDialogButtonProperties( + onClick = { onDeleteGroup(state) }, + text = stringResource(id = R.string.leave_conversation_admin_options_dialog_delete_button), + type = WireDialogButtonType.Primary, + ), + dismissButtonProperties = WireDialogButtonProperties( + onClick = dialogState::dismiss, + text = stringResource(id = R.string.label_cancel), + ), + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminNavArgs.kt new file mode 100644 index 00000000000..b6248a22053 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminNavArgs.kt @@ -0,0 +1,28 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.promoteadmin + +import android.os.Parcelable +import com.wire.android.ui.home.conversations.QualifiedIdParceler +import com.wire.kalium.logic.data.id.ConversationId +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler + +@Parcelize +@TypeParceler() +data class PromoteAdminNavArgs(val conversationId: ConversationId) : Parcelable diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminScreen.kt new file mode 100644 index 00000000000..6086c9d21e4 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminScreen.kt @@ -0,0 +1,200 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.promoteadmin + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.wire.android.R +import com.wire.android.model.Clickable +import com.wire.android.model.ItemActionType +import com.wire.android.navigation.Navigator +import com.wire.android.navigation.annotation.app.WireRootDestination +import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.ui.common.SearchBarInput +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryButton +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.topappbar.NavigationIconType +import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.home.conversations.search.InternalContactSearchResultItem +import com.wire.android.ui.home.conversationslist.model.Membership +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.util.ui.PreviewMultipleThemes +import com.wire.kalium.logic.data.user.ConnectionState +import com.wire.kalium.logic.data.user.UserId +import com.wire.android.ui.common.R as commonR + +@WireRootDestination( + navArgs = PromoteAdminNavArgs::class, + style = PopUpNavigationAnimation::class, +) +@Composable +fun PromoteAdminScreen( + navigator: Navigator, + viewModel: PromoteAdminViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + PromoteAdminContent( + state = state, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onUserSelected = viewModel::onUserSelected, + onPromoteAdminAndLeave = viewModel::onPromoteAdminAndLeave, + onClose = navigator::navigateBack, + ) +} + +@Composable +private fun PromoteAdminContent( + state: PromoteAdminState, + onSearchQueryChanged: (String) -> Unit, + onUserSelected: (UserId) -> Unit, + onPromoteAdminAndLeave: () -> Unit, + onClose: () -> Unit, +) { + val searchTextState = rememberTextFieldState() + + LaunchedEffect(Unit) { + snapshotFlow { searchTextState.text.toString() } + .collect { onSearchQueryChanged(it) } + } + + WireScaffold( + topBar = { + WireCenterAlignedTopAppBar( + title = stringResource(R.string.promote_admin_screen_title), + navigationIconType = NavigationIconType.Close(R.string.content_description_close_button), + elevation = dimensions().spacing0x, + onNavigationPressed = onClose, + ) + }, + bottomBar = { + Surface( + color = MaterialTheme.wireColorScheme.background, + shadowElevation = MaterialTheme.wireDimensions.bottomNavigationShadowElevation, + ) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensions().spacing16x) + .height(dimensions().groupButtonHeight), + ) { + WirePrimaryButton( + text = stringResource(R.string.promote_admin_button), + onClick = onPromoteAdminAndLeave, + state = if (state.isButtonEnabled) WireButtonState.Default else WireButtonState.Disabled, + ) + } + } + }, + ) { internalPadding -> + LazyColumn(modifier = Modifier.padding(internalPadding)) { + item { + SearchBarInput( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensions().spacing16x, + vertical = dimensions().spacing8x, + ), + placeholderText = stringResource(R.string.promote_admin_search_placeholder), + leadingIcon = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(start = dimensions().spacing4x) + .size(dimensions().buttonCircleMinSize) + ) { + Icon( + painter = painterResource(id = commonR.drawable.ic_search), + contentDescription = stringResource(commonR.string.content_description_conversation_search_icon), + tint = MaterialTheme.wireColorScheme.onBackground + ) + } + }, + textState = searchTextState, + ) + } + items(state.filteredMembers, key = { it.userId.toString() }) { member -> + InternalContactSearchResultItem( + avatarData = member.avatarData, + name = member.name, + label = member.handle, + membership = Membership.Standard, + searchQuery = state.searchQuery, + connectionState = ConnectionState.ACCEPTED, + isSelected = state.selectedUserId == member.userId, + actionType = ItemActionType.CHECK, + onCheckClickable = Clickable { onUserSelected(member.userId) }, + clickable = Clickable { onUserSelected(member.userId) }, + ) + } + } + } +} + +@PreviewMultipleThemes +@Composable +private fun PreviewPromoteAdminScreen() = WireTheme { + PromoteAdminContent( + state = PromoteAdminState( + filteredMembers = listOf( + PromoteAdminMemberItem( + userId = UserId("user1", "wire.com"), + name = "Alice", + handle = "@alice", + ), + PromoteAdminMemberItem( + userId = UserId("user2", "wire.com"), + name = "Bob", + handle = "@bob", + ), + ), + selectedUserId = UserId("user1", "wire.com"), + ), + onSearchQueryChanged = {}, + onUserSelected = {}, + onPromoteAdminAndLeave = {}, + onClose = {}, + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModel.kt new file mode 100644 index 00000000000..de5cac8bd0b --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModel.kt @@ -0,0 +1,117 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.promoteadmin + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.composedestinations.generated.app.navArgs +import com.wire.android.model.UserAvatarData +import com.wire.android.ui.home.conversations.avatar +import com.wire.kalium.logic.data.conversation.MemberDetails +import com.wire.kalium.logic.data.user.OtherUser +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.conversation.ObserveEligibleMembersForConversationAdminRoleUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PromoteAdminViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val observeEligibleMembers: ObserveEligibleMembersForConversationAdminRoleUseCase, +) : ViewModel() { + + private val navArgs: PromoteAdminNavArgs = savedStateHandle.navArgs() + + private val allMembers = MutableStateFlow>(emptyList()) + private val searchQuery = MutableStateFlow("") + private val selectedUserId = MutableStateFlow(null) + + private val _state: MutableStateFlow = MutableStateFlow(PromoteAdminState()) + val state = _state.asStateFlow() + + init { + viewModelScope.launch { + combine(allMembers, searchQuery, selectedUserId) { members, query, selected -> + PromoteAdminState( + searchQuery = query, + filteredMembers = filter(members, query), + selectedUserId = selected, + isButtonEnabled = selected != null, + ) + }.collect { newState -> + _state.update { newState } + } + } + + viewModelScope.launch { + observeEligibleMembers(navArgs.conversationId).collect { members -> + allMembers.value = members.map { it.toMemberItem() } + } + } + } + + fun onSearchQueryChanged(query: String) { + searchQuery.value = query + } + + fun onUserSelected(userId: UserId) { + selectedUserId.value = if (selectedUserId.value == userId) null else userId + } + + fun onPromoteAdminAndLeave() { + TODO("implement with use cases") + } + + private fun filter(members: List, query: String): List = + if (query.isBlank()) { + members + } else { + val normalized = query.removePrefix("@") + members.filter { + it.name.contains(normalized, ignoreCase = true) || + it.handle.contains(normalized, ignoreCase = true) + } + } + + private fun MemberDetails.toMemberItem() = PromoteAdminMemberItem( + userId = user.id, + name = user.name.orEmpty(), + handle = user.handle.orEmpty(), + avatarData = user.avatar(connectionState = (user as? OtherUser)?.connectionStatus), + ) +} + +data class PromoteAdminState( + val searchQuery: String = "", + val filteredMembers: List = emptyList(), + val selectedUserId: UserId? = null, + val isButtonEnabled: Boolean = false, +) + +data class PromoteAdminMemberItem( + val userId: UserId, + val name: String, + val handle: String, + val avatarData: UserAvatarData = UserAvatarData(), +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt index 1cabbc64db2..1f75bca5bf1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationsScreenContent.kt @@ -37,6 +37,7 @@ import com.ramcosta.composedestinations.generated.app.destinations.ConversationS import com.ramcosta.composedestinations.generated.app.destinations.DebugConversationScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.NewConversationSearchPeopleScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.PromoteAdminScreenDestination import com.wire.android.R import com.wire.android.appLogger import com.wire.android.feature.analytics.AnonymousAnalyticsManagerImpl @@ -58,6 +59,7 @@ import com.wire.android.ui.common.search.rememberSearchbarState import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.debug.conversation.DebugConversationScreenNavArgs import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState +import com.wire.android.ui.home.conversations.promoteadmin.PromoteAdminNavArgs import com.wire.android.ui.home.conversationslist.common.ConversationList import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.conversationslist.model.ConversationItemType @@ -245,6 +247,7 @@ fun ConversationsScreenContent( ConversationOptionsModalSheetLayout( sheetState = sheetState, openConversationFolders = { navigator.navigate(NavigationCommand(ConversationFoldersScreenDestination(it))) }, + onPromoteAdmin = { navigator.navigate(NavigationCommand(PromoteAdminScreenDestination(PromoteAdminNavArgs(it)))) }, openConversationDebugMenu = { conversationId -> navigator.navigate( NavigationCommand( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt index cfb80a2a7e3..bceab67d0f9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt @@ -42,6 +42,12 @@ data class LeaveGroupDialogState( val loading: Boolean = false, ) : GroupDialogState(conversationId, conversationName) +data class LeaveGroupOptionsDialogState( + override val conversationId: ConversationId, + override val conversationName: String, + val showPromoteOption: Boolean, +) : GroupDialogState(conversationId, conversationName) + data class DeleteGroupDialogState( override val conversationId: ConversationId, override val conversationName: String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4796c023380..0b186cad5e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -665,6 +665,13 @@ Leave “%s”? You will then no longer be able to send or read messages in this conversation on any device. Delete the conversation and its content for me on all my devices + You\'re the only admin.\nPromote another participant before leaving, or delete the group if it is no longer needed. + You\'re the only admin. The other participants can\'t be admins.\nAdd at least one team member and promote them as an admin before you leave. Alternatively, delete the group if it is no longer needed. + Promote new admin + Delete group + Promote new admin + Enter a name or email + Promote as admin and leave group Delete Conversation… Remove “%s”? The conversation will be removed from your conversations list on all devices. You will no longer be able to access the conversation and its content. diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModelTest.kt new file mode 100644 index 00000000000..ed9f2921698 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/promoteadmin/PromoteAdminViewModelTest.kt @@ -0,0 +1,191 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.promoteadmin + +import androidx.lifecycle.SavedStateHandle +import com.ramcosta.composedestinations.generated.app.navArgs +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.NavigationTestExtension +import com.wire.android.mapper.testOtherUser +import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.conversation.MemberDetails +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.conversation.ObserveEligibleMembersForConversationAdminRoleUseCase +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class) +class PromoteAdminViewModelTest { + + @Test + fun givenNoUserSelected_whenScreenLoads_thenButtonIsDisabled() = runTest { + val (_, viewModel) = Arrangement().arrange() + + assertFalse(viewModel.state.value.isButtonEnabled) + assertNull(viewModel.state.value.selectedUserId) + } + + @Test + fun givenUserSelected_whenSameUserSelectedAgain_thenSelectionCleared() = runTest { + val userId = UserId("user1", "wire.com") + val (_, viewModel) = Arrangement().arrange() + + viewModel.onUserSelected(userId) + assertTrue(viewModel.state.value.isButtonEnabled) + + viewModel.onUserSelected(userId) + assertFalse(viewModel.state.value.isButtonEnabled) + assertNull(viewModel.state.value.selectedUserId) + } + + @Test + fun givenUserSelected_whenDifferentUserSelected_thenOnlyNewUserSelected() = runTest { + val userId1 = UserId("user1", "wire.com") + val userId2 = UserId("user2", "wire.com") + val (_, viewModel) = Arrangement().arrange() + + viewModel.onUserSelected(userId1) + assertEquals(userId1, viewModel.state.value.selectedUserId) + + viewModel.onUserSelected(userId2) + assertEquals(userId2, viewModel.state.value.selectedUserId) + } + + @Test + fun givenSearchQuery_whenFiltering_thenOnlyMatchingMembersReturned() = runTest { + val (_, viewModel) = Arrangement() + .withEligibleMembers(listOf(member(0, "Alice", "alice"), member(1, "Bob", "bob"))) + .arrange() + advanceUntilIdle() + + viewModel.onSearchQueryChanged("ali") + advanceUntilIdle() + + val filtered = viewModel.state.value.filteredMembers + assertEquals(1, filtered.size) + assertEquals("Alice", filtered.first().name) + } + + @Test + fun givenEmptySearchQuery_whenFiltering_thenAllMembersReturned() = runTest { + val (_, viewModel) = Arrangement() + .withEligibleMembers(listOf(member(0, "Alice", "alice"), member(1, "Bob", "bob"))) + .arrange() + advanceUntilIdle() + + viewModel.onSearchQueryChanged("") + advanceUntilIdle() + + assertEquals(2, viewModel.state.value.filteredMembers.size) + } + + @Test + fun givenSearchQueryMatchesHandle_whenFiltering_thenMemberReturned() = runTest { + val (_, viewModel) = Arrangement() + .withEligibleMembers(listOf(member(0, "Alice", "alicewonder"), member(1, "Bob", "bobby"))) + .arrange() + advanceUntilIdle() + + viewModel.onSearchQueryChanged("wonder") + advanceUntilIdle() + + val filtered = viewModel.state.value.filteredMembers + assertEquals(1, filtered.size) + assertEquals("Alice", filtered.first().name) + } + + @Test + fun givenSearchQueryStartsWithAt_whenFiltering_thenLeadingAtIsStrippedAndHandleMatched() = runTest { + val (_, viewModel) = Arrangement() + .withEligibleMembers(listOf(member(0, "Alice", "alicewonder"), member(1, "Bob", "bobby"))) + .arrange() + advanceUntilIdle() + + viewModel.onSearchQueryChanged("@alice") + advanceUntilIdle() + + val filtered = viewModel.state.value.filteredMembers + assertEquals(1, filtered.size) + assertEquals("Alice", filtered.first().name) + } + + @Test + fun givenSearchQueryMatchesNeitherNameNorHandle_whenFiltering_thenResultIsEmpty() = runTest { + val (_, viewModel) = Arrangement() + .withEligibleMembers(listOf(member(0, "Alice", "alicewonder"), member(1, "Bob", "bobby"))) + .arrange() + advanceUntilIdle() + + viewModel.onSearchQueryChanged("charlie") + advanceUntilIdle() + + assertTrue(viewModel.state.value.filteredMembers.isEmpty()) + } + + private fun member(index: Int, name: String, handle: String): MemberDetails = + MemberDetails( + testOtherUser(index).copy(name = name, handle = handle), + Conversation.Member.Role.Member, + ) + + @Test + fun givenNoEligibleMembers_whenViewModelCreated_thenAllMembersIsEmpty() = runTest { + val (_, viewModel) = Arrangement() + .withEligibleMembers(emptyList()) + .arrange() + + advanceUntilIdle() + + assertTrue(viewModel.state.value.filteredMembers.isEmpty()) + } + + private inner class Arrangement { + @MockK + lateinit var savedStateHandle: SavedStateHandle + + @MockK + lateinit var observeEligibleMembers: ObserveEligibleMembersForConversationAdminRoleUseCase + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + every { savedStateHandle.navArgs() } returns + PromoteAdminNavArgs(ConversationId("conv1", "wire.com")) + coEvery { observeEligibleMembers(any()) } returns flowOf(emptyList()) + } + + fun withEligibleMembers(members: List) = apply { + coEvery { observeEligibleMembers(any()) } returns flowOf(members) + } + + fun arrange() = this to PromoteAdminViewModel(savedStateHandle, observeEligibleMembers) + } +} diff --git a/kalium b/kalium index f69a608d512..c7bc36514a7 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit f69a608d5126385b2a1284845657c0bbf11f9c65 +Subproject commit c7bc36514a75f7398e0f7f85a981561ec1fb023a From d9d46e2667c99d2552a0bbee6c00a7064013f33e Mon Sep 17 00:00:00 2001 From: Sergei Bakhtiarov Date: Fri, 15 May 2026 15:35:55 +0200 Subject: [PATCH 2/2] feat: leave options dialog without delete option (WPB-25278) --- .../ConversationOptionsMenuViewModel.kt | 11 +++- .../LeaveConversationAdminOptionsDialog.kt | 65 ++++++++++++++----- .../model/GroupDialogState.kt | 1 + app/src/main/res/values/strings.xml | 3 + kalium | 2 +- 5 files changed, 62 insertions(+), 20 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsMenuViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsMenuViewModel.kt index c72e6f7f011..74ea32b1794 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsMenuViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationOptionsMenuViewModel.kt @@ -71,6 +71,8 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.onCompletion @@ -260,7 +262,8 @@ class ConversationOptionsMenuViewModelImpl @Inject constructor( LeaveGroupOptionsDialogState( conversationId = leaveGroupState.conversationId, conversationName = leaveGroupState.conversationName, - showPromoteOption = result.eligibleUsersAvailable + showPromoteOption = result.eligibleUsersAvailable, + canDeleteGroup = canDeleteGroup(leaveGroupState.conversationId), ) ) } @@ -274,6 +277,12 @@ class ConversationOptionsMenuViewModelImpl @Inject constructor( } } + private suspend fun canDeleteGroup(conversationId: ConversationId) = observeConversationStateFlow(conversationId) + .filterIsInstance() + .firstOrNull() + ?.conversation + ?.canDeleteGroup() ?: false + override fun leaveGroup(conversationId: ConversationId, conversationName: String, shouldDelete: Boolean) { viewModelScope.launch { leaveGroupDialogState.update { it.copy(loading = true) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/LeaveConversationAdminOptionsDialog.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/LeaveConversationAdminOptionsDialog.kt index 2c313cb95a8..e6260437d64 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/LeaveConversationAdminOptionsDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/menu/LeaveConversationAdminOptionsDialog.kt @@ -35,33 +35,62 @@ internal fun LeaveConversationAdminOptionsDialog( onDeleteGroup: (LeaveGroupOptionsDialogState) -> Unit, ) { VisibilityState(dialogState) { state -> + val isInformationalOnly = !state.showPromoteOption && !state.canDeleteGroup WireDialog( - title = stringResource(id = R.string.leave_conversation_dialog_title, state.conversationName), - text = if (state.showPromoteOption) { - stringResource(id = R.string.leave_conversation_admin_options_dialog_description_with_promote) - } else { - stringResource(id = R.string.leave_conversation_admin_options_dialog_description_no_promote) - }, + title = stringResource(id = titleRes(state), state.conversationName), + text = stringResource(id = descriptionRes(state)), buttonsHorizontalAlignment = false, onDismiss = dialogState::dismiss, - optionButton1Properties = if (state.showPromoteOption) { - WireDialogButtonProperties( + optionButton1Properties = when { + isInformationalOnly -> WireDialogButtonProperties( + onClick = dialogState::dismiss, + text = stringResource(id = R.string.label_ok), + type = WireDialogButtonType.Primary, + ) + state.showPromoteOption -> WireDialogButtonProperties( onClick = { onPromoteAdmin(state) }, text = stringResource(id = R.string.leave_conversation_admin_options_dialog_promote_button), type = WireDialogButtonType.Primary, ) - } else { + else -> null + }, + optionButton2Properties = when { + !isInformationalOnly && state.canDeleteGroup -> WireDialogButtonProperties( + onClick = { onDeleteGroup(state) }, + text = stringResource(id = R.string.leave_conversation_admin_options_dialog_delete_button), + type = WireDialogButtonType.Primary, + ) + else -> null + }, + dismissButtonProperties = if (isInformationalOnly) { null + } else { + WireDialogButtonProperties( + onClick = dialogState::dismiss, + text = stringResource(id = R.string.label_cancel), + ) }, - optionButton2Properties = WireDialogButtonProperties( - onClick = { onDeleteGroup(state) }, - text = stringResource(id = R.string.leave_conversation_admin_options_dialog_delete_button), - type = WireDialogButtonType.Primary, - ), - dismissButtonProperties = WireDialogButtonProperties( - onClick = dialogState::dismiss, - text = stringResource(id = R.string.label_cancel), - ), ) } } + +@Composable +private fun descriptionRes(state: LeaveGroupOptionsDialogState): Int = when { + state.showPromoteOption && state.canDeleteGroup -> + R.string.leave_conversation_admin_options_dialog_description_with_promote + + state.showPromoteOption && !state.canDeleteGroup -> + R.string.leave_conversation_admin_options_dialog_description_with_promote_no_delete + + !state.showPromoteOption && state.canDeleteGroup -> + R.string.leave_conversation_admin_options_dialog_description_no_promote + + else -> + R.string.leave_conversation_admin_options_dialog_description_no_promote_no_delete +} + +@Composable +private fun titleRes(state: LeaveGroupOptionsDialogState): Int = when { + state.canDeleteGroup || state.showPromoteOption -> R.string.leave_conversation_dialog_title + else -> R.string.cannot_leave_conversation_dialog_title +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt index bceab67d0f9..86e8b529980 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt @@ -46,6 +46,7 @@ data class LeaveGroupOptionsDialogState( override val conversationId: ConversationId, override val conversationName: String, val showPromoteOption: Boolean, + val canDeleteGroup: Boolean, ) : GroupDialogState(conversationId, conversationName) data class DeleteGroupDialogState( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0b186cad5e5..d0455909e13 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -663,10 +663,13 @@ Current apps will be removed from the conversation. New apps will not be allowed. Leave Conversation… Leave “%s”? + Cannot leave “%s”. You will then no longer be able to send or read messages in this conversation on any device. Delete the conversation and its content for me on all my devices You\'re the only admin.\nPromote another participant before leaving, or delete the group if it is no longer needed. + You\'re the only admin.\nPromote another participant before leaving. You\'re the only admin. The other participants can\'t be admins.\nAdd at least one team member and promote them as an admin before you leave. Alternatively, delete the group if it is no longer needed. + There are no other eligible admins in the group and as a personal user you cannot delete the group. However, add another team member and make that user admin to delete the group. Promote new admin Delete group Promote new admin diff --git a/kalium b/kalium index c7bc36514a7..ac51876ffcd 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit c7bc36514a75f7398e0f7f85a981561ec1fb023a +Subproject commit ac51876ffcddb1e12b10bdb23aec3d38e6dcdfda