diff --git a/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt b/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt index d2a2c5239f..7e5cd7a7d1 100644 --- a/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt +++ b/app/src/main/java/org/session/libsession/messaging/groups/GroupInviteException.kt @@ -43,7 +43,7 @@ class GroupInviteException( if (second != null && third != null) { val errorString = - if (isPromotion) R.string.adminPromotionFailedDescriptionMultiple else + if (isPromotion) if (isReinvite) R.string.failedResendPromotionMultiple else R.string.adminPromotionFailedDescriptionMultiple else if (isReinvite) R.string.failedResendInviteMultiple else R.string.groupInviteFailedMultiple return Phrase.from(context, errorString) .put(NAME_KEY, first) @@ -51,7 +51,7 @@ class GroupInviteException( .put(GROUP_NAME_KEY, groupName) .format() } else if (second != null) { - val errorString = if (isPromotion) R.string.adminPromotionFailedDescriptionTwo else + val errorString = if (isPromotion) if (isReinvite) R.string.failedResendPromotionTwo else R.string.adminPromotionFailedDescriptionTwo else if (isReinvite) R.string.failedResendInviteTwo else R.string.groupInviteFailedTwo return Phrase.from(context, errorString) .put(NAME_KEY, first) @@ -59,7 +59,7 @@ class GroupInviteException( .put(GROUP_NAME_KEY, groupName) .format() } else { - val errorString = if (isPromotion) R.string.adminPromotionFailedDescription else + val errorString = if (isPromotion) if (isReinvite) R.string.failedResendPromotion else R.string.adminPromotionFailedDescription else if (isReinvite) R.string.failedResendInvite else R.string.groupInviteFailedUser return Phrase.from(context, errorString) .put(NAME_KEY, first) diff --git a/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt b/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt index 3384628c39..8fafdd9e08 100644 --- a/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt +++ b/app/src/main/java/org/session/libsession/messaging/groups/GroupManagerV2.kt @@ -125,6 +125,10 @@ interface GroupManagerV2 { fun getLeaveGroupConfirmationDialogData(groupId: AccountId, name: String): ConfirmDialogData? + fun getAdminLeaveGroupDialogData(groupId : AccountId, name : String) : ConfirmDialogData? + + fun isCurrentUserLastAdmin(groupId : AccountId) : Boolean + data class ConfirmDialogData( val title: String, val message: CharSequence, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index d058fb29f9..2492c99893 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -80,6 +80,8 @@ fun ConversationSettingsDialogs( buttons.add( DialogButtonData( text = GetString(dialogsState.showSimpleDialog.negativeText), + color = if (dialogsState.showSimpleDialog.negativeStyleDanger) LocalColors.current.danger + else LocalColors.current.text, qaTag = dialogsState.showSimpleDialog.negativeQaTag, onClick = dialogsState.showSimpleDialog.onNegative ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 3366c335fe..50891467e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -14,6 +14,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.dropUnlessResumed import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.NavBackStackEntry import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute @@ -31,10 +32,14 @@ import org.thoughtcrime.securesms.conversation.v2.settings.notification.Notifica import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel import org.thoughtcrime.securesms.groups.GroupMembersViewModel import org.thoughtcrime.securesms.groups.InviteMembersViewModel +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel import org.thoughtcrime.securesms.groups.compose.ManageGroupMembersScreen import org.thoughtcrime.securesms.groups.compose.GroupMembersScreen import org.thoughtcrime.securesms.groups.compose.InviteAccountIdScreen import org.thoughtcrime.securesms.groups.compose.InviteContactsScreen +import org.thoughtcrime.securesms.groups.compose.ManageGroupAdminsScreen +import org.thoughtcrime.securesms.groups.compose.PromoteMembersScreen import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel import org.thoughtcrime.securesms.home.startconversation.newmessage.State import org.thoughtcrime.securesms.media.MediaOverviewScreen @@ -71,6 +76,30 @@ sealed interface ConversationSettingsDestination: Parcelable { val groupAddress: Address.Group get() = Address.Group(AccountId(address)) } + @Serializable + @Parcelize + data class RouteManageAdmins private constructor( + private val address: String, + val navigateToPromoteMembers: Boolean = false + ) : ConversationSettingsDestination { + constructor(groupAddress: Address.Group, navigateToPromoteMembers: Boolean = false) : this( + groupAddress.address, + navigateToPromoteMembers + ) + + val groupAddress: Address.Group get() = Address.Group(AccountId(address)) + } + + @Serializable + @Parcelize + data class RoutePromoteMembers( + private val address: String + ): ConversationSettingsDestination { + constructor(groupAddress: Address.Group): this(groupAddress.address) + + val groupAddress: Address.Group get() = Address.Group(AccountId(address)) + } + @Serializable @Parcelize data class RouteInviteToGroup private constructor( @@ -213,6 +242,23 @@ fun ConversationSettingsNavHost( ) } + // Manage group Admins + horizontalSlideComposable { backStackEntry -> + val data: RouteManageAdmins = backStackEntry.toRoute() + + val viewModel = + hiltViewModel { factory -> + factory.create(data.groupAddress, navigator, data.navigateToPromoteMembers) + } + + ManageGroupAdminsScreen( + viewModel = viewModel, + onBack = dropUnlessResumed { + handleBack() + }, + ) + } + // Invite Contacts to group horizontalSlideComposable { backStackEntry -> val data: RouteInviteToGroup = backStackEntry.toRoute() @@ -335,6 +381,32 @@ fun ConversationSettingsNavHost( ) } + // Promote Members to group Admin + horizontalSlideComposable { backStackEntry -> + val data: RoutePromoteMembers = backStackEntry.toRoute() + + val viewModel = + hiltViewModel { factory -> + factory.create(groupAddress = data.groupAddress) + } + + val parentEntry = remember(backStackEntry) { + navController.previousBackStackEntry ?: error("RouteManageAdmin not in backstack") + } + val manageGroupAdminsViewModel: ManageGroupAdminsViewModel = hiltViewModel(parentEntry) + + PromoteMembersScreen( + viewModel = viewModel, + onBack = dropUnlessResumed { + handleBack() + }, + onPromoteClicked = { selectedMembers -> + manageGroupAdminsViewModel.onSendPromotionsClicked(selectedMembers) + handleBack() + } + ) + } + // Disappearing Messages horizontalSlideComposable { val viewModel: DisappearingMessagesViewModel = diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 4b26d8d43b..4439797398 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.allWithStatus import network.loki.messenger.libsession_util.util.ExpiryMode import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 @@ -284,10 +285,10 @@ class ConversationSettingsViewModel @AssistedInject constructor( OptionsItem( name = context.getString(R.string.manageAdmins), icon = R.drawable.ic_add_admin_custom, - qaTag = R.string.qa_conversation_settings_manage_members, + qaTag = R.string.qa_conversation_settings_manage_admins, onClick = { (address as? Address.Group)?.let { - navigateTo(ConversationSettingsDestination.RouteManageMembers(it)) + navigateTo(ConversationSettingsDestination.RouteManageAdmins(it)) } } ) @@ -311,6 +312,16 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) } + private val optionAdminLeaveGroup: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.groupLeave), + icon = R.drawable.ic_log_out, + qaTag = R.string.qa_conversation_settings_leave_group, + onClick = ::confirmAdminLeaveGroup + ) + } + + // Community private val optionCopyCommunityURL: OptionsItem by lazy{ OptionsItem( @@ -580,7 +591,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( dangerOptions.addAll( listOf( optionClearMessages, - optionLeaveGroup, + optionAdminLeaveGroup, optionDeleteGroup ) ) @@ -1026,8 +1037,50 @@ class ConversationSettingsViewModel @AssistedInject constructor( } } + private fun confirmAdminLeaveGroup(){ + val groupV2Id = (address as? Address.Group)?.accountId ?: return + val isUserLastAdmin = groupManager.isCurrentUserLastAdmin(groupV2Id) + _dialogState.update { state -> + val dialogData = groupManager.getAdminLeaveGroupDialogData( + groupV2Id, + _uiState.value.name + ) ?: return + + state.copy( + showSimpleDialog = SimpleDialogData( + title = dialogData.title, + message = dialogData.message, + positiveText = context.getString(dialogData.positiveText), + negativeText = context.getString(dialogData.negativeText), + positiveQaTag = dialogData.positiveQaTag?.let { context.getString(it) }, + negativeQaTag = dialogData.negativeQaTag?.let { context.getString(it) }, + onPositive = { + if (isUserLastAdmin){ + // Calling this to have the ManageAdminScreen in the backstack so we can + // get its VM and PromoteMembersScreen can navigate back to it after sending promotions + navigateTo( + ConversationSettingsDestination.RouteManageAdmins( + groupAddress = address, + navigateToPromoteMembers = true + ) + ) + }else{ + leaveGroup() + } + }, + positiveStyleDanger = !isUserLastAdmin, + onNegative = { + if (isUserLastAdmin) confirmLeaveGroup() + }, + negativeStyleDanger = isUserLastAdmin // red color on the right + ) + ) + } + } + private fun confirmLeaveGroup(){ val groupV2Id = (address as? Address.Group)?.accountId ?: return + _dialogState.update { state -> val dialogData = groupManager.getLeaveGroupConfirmationDialogData( groupV2Id, @@ -1040,8 +1093,8 @@ class ConversationSettingsViewModel @AssistedInject constructor( message = dialogData.message, positiveText = context.getString(dialogData.positiveText), negativeText = context.getString(dialogData.negativeText), - positiveQaTag = dialogData.positiveQaTag?.let{ context.getString(it) }, - negativeQaTag = dialogData.negativeQaTag?.let{ context.getString(it) }, + positiveQaTag = dialogData.positiveQaTag?.let { context.getString(it) }, + negativeQaTag = dialogData.negativeQaTag?.let { context.getString(it) }, onPositive = ::leaveGroup, onNegative = {} ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt index e335a661f2..fe897f2f63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -1,11 +1,14 @@ package org.thoughtcrime.securesms.groups import android.content.Context +import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -16,6 +19,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.allWithStatus @@ -32,6 +36,7 @@ import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUtils import java.util.EnumSet +import kotlin.coroutines.cancellation.CancellationException abstract class BaseGroupMembersViewModel( groupAddress: Address.Group, @@ -76,6 +81,11 @@ abstract class BaseGroupMembersViewModel( } }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + // Current group name (for header / text, if needed) + val groupName: StateFlow = groupInfo + .map { it?.first?.name.orEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") + private val mutableSearchQuery = MutableStateFlow("") val searchQuery: StateFlow get() = mutableSearchQuery @@ -92,11 +102,33 @@ abstract class BaseGroupMembersViewModel( .map { list -> list.filter { !it.showAsAdmin } } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + // Output : List of active members that can be promoted + val activeMembers: StateFlow> = members + .map { list -> list.filter { !it.showAsAdmin && it.status == GroupMember.Status.INVITE_ACCEPTED } } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val hasActiveMembers: StateFlow = + groupInfo + .map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin && it.status == GroupMember.Status.INVITE_ACCEPTED } } + .stateIn(viewModelScope, SharingStarted.Lazily, false) + val hasNonAdminMembers: StateFlow = groupInfo .map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin } } .stateIn(viewModelScope, SharingStarted.Lazily, false) + // Output: List of only ADMINS + val adminMembers: StateFlow> = members + .map { list -> + list.filter { it.showAsAdmin } + .sortedWith( + compareBy { adminOrder(it) } + .thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + .thenBy { it.accountId } + ) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + fun onSearchQueryChanged(query: String) { mutableSearchQuery.value = query } @@ -148,7 +180,8 @@ abstract class BaseGroupMembersViewModel( showProBadge = shouldShowProBadge, avatarUIData = avatarUtils.getUIDataFromAccountId(memberAccountId.hexString), clickable = !isMyself, - statusLabel = getMemberLabel(status, context, amIAdmin) + statusLabel = getMemberLabel(status, context, amIAdmin), + isSelf = isMyself ) } @@ -188,6 +221,44 @@ abstract class BaseGroupMembersViewModel( .thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) .thenBy { it.accountId } ) + + fun showToast(text: String) { + Toast.makeText( + context, text, Toast.LENGTH_SHORT + ).show() + } + + /** + * Perform a group operation, such as inviting a member, removing a member. + * + * This is a helper function that encapsulates the common error handling and progress tracking. + */ + protected fun performGroupOperationCore( + showLoading: Boolean = false, + setLoading: (Boolean) -> Unit = {}, + errorMessage: ((Throwable) -> String?)? = null, + operation: suspend () -> Unit + ) { + viewModelScope.launch { + if (showLoading) setLoading(true) + + // We need to use GlobalScope here because we don't want + // any group operation to be cancelled when the view model is cleared. + @Suppress("OPT_IN_USAGE") + val task = GlobalScope.async { + operation() + } + + try { + task.await() + } catch (e: Throwable) { + val msg = errorMessage?.invoke(e) ?: context.getString(R.string.errorUnknown) + showToast(msg) + } finally { + if (showLoading) setLoading(false) + } + } + } } private fun stateOrder(status: GroupMember.Status?): Int = when (status) { @@ -209,6 +280,18 @@ private fun stateOrder(status: GroupMember.Status?): Int = when (status) { else -> 6 } +private fun adminOrder(state: GroupMemberState): Int { + if (state.isSelf) return 7 // "You" always last + return when (state.status) { + GroupMember.Status.PROMOTION_FAILED -> 1 + GroupMember.Status.PROMOTION_NOT_SENT -> 2 + GroupMember.Status.PROMOTION_UNKNOWN -> 3 + GroupMember.Status.PROMOTION_SENDING -> 4 + GroupMember.Status.PROMOTION_SENT -> 5 + else -> 6 + } +} + data class GroupMemberState( val accountId: AccountId, val avatarUIData: AvatarUIData, @@ -222,7 +305,8 @@ data class GroupMemberState( val canRemove: Boolean, val canPromote: Boolean, val clickable: Boolean, - val statusLabel: String + val statusLabel: String, + val isSelf: Boolean ) { val canEdit: Boolean get() = canRemove || canPromote || canResendInvite || canResendPromotion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index e7ed8240cc..5fc4fe2813 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -15,6 +15,7 @@ import network.loki.messenger.R import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.allWithStatus import network.loki.messenger.libsession_util.util.Bytes.Companion.toBytes import network.loki.messenger.libsession_util.util.Conversation import network.loki.messenger.libsession_util.util.ExpiryMode @@ -564,16 +565,16 @@ class GroupManagerV2Impl @Inject constructor( // Wait and gather all the promote message sending result into a result map val promotedByMemberIDs = promotionDeferred - .mapValues { - runCatching { it.value.await() }.isSuccess + .mapValues { (_, deferred) -> + runCatching { deferred.await() } } // Update each member's status configFactory.withMutableGroupConfigs(group) { configs -> promotedByMemberIDs.asSequence() - .mapNotNull { (member, success) -> + .mapNotNull { (member, result) -> configs.groupMembers.get(member.hexString)?.apply { - if (success) { + if (result.isSuccess) { setPromotionSent() } else { setPromotionFailed() @@ -583,6 +584,25 @@ class GroupManagerV2Impl @Inject constructor( .forEach(configs.groupMembers::set) } + val failedMembers = promotedByMemberIDs + .filterValues { it.isFailure } + .keys + .toList() + + if (failedMembers.isNotEmpty()) { + val cause = promotedByMemberIDs.values + .firstOrNull { it.isFailure } + ?.exceptionOrNull() + ?: RuntimeException("Failed to promote ${failedMembers.size} member(s)") + + throw GroupInviteException( + isPromotion = true, + inviteeAccountIds = failedMembers.map { it.hexString }, + groupName = groupName ?: "", + isReinvite = isRepromote, + underlying = cause + ) + } if (!isRepromote) { messageSender.sendAndAwait(message, Address.fromSerialized(group.hexString)) @@ -1207,7 +1227,6 @@ class GroupManagerV2Impl @Inject constructor( negativeQaTag = R.string.qa_conversation_settings_dialog_leave_group_cancel } - return GroupManagerV2.ConfirmDialogData( title = application.getString(title), message = message, @@ -1218,6 +1237,63 @@ class GroupManagerV2Impl @Inject constructor( ) } + override fun getAdminLeaveGroupDialogData( + groupId: AccountId, + name: String + ): GroupManagerV2.ConfirmDialogData? { + val title = R.string.groupLeave + var message: CharSequence = "" + var positiveButton = R.string.leave + var negativeButton = R.string.cancel + var positiveQaTag = R.string.qa_conversation_settings_dialog_leave_group_confirm + var negativeQaTag = R.string.qa_conversation_settings_dialog_leave_group_cancel + + if (isCurrentUserLastAdmin(groupId)) { + message = Phrase.from(application, R.string.groupOnlyAdmin) + .put(GROUP_NAME_KEY, name) + .format() + positiveButton = R.string.add + negativeButton = R.string.groupDelete + } else { + message = Phrase.from(application, R.string.groupLeaveDescription) + .put(GROUP_NAME_KEY, name) + .format() + } + + return GroupManagerV2.ConfirmDialogData( + title = application.getString(title), + message = message, + positiveText = positiveButton, + negativeText = negativeButton, + positiveQaTag = positiveQaTag, + negativeQaTag = negativeQaTag, + ) + } + + override fun isCurrentUserLastAdmin(groupId: AccountId): Boolean { + val currentUserId = checkNotNull(storage.getUserPublicKey()) { "User public key is null" } + + val membersWithStatus = configFactory.withGroupConfigs(groupId) { + it.groupMembers.allWithStatus() + } + + var adminCount = 0 + var amAdmin = false + + for ((member, status) in membersWithStatus) { + val isAdminLike = status == GroupMember.Status.PROMOTION_ACCEPTED && !member.isRemoved(status) + if (!isAdminLike) continue + + adminCount++ + + if (member.accountId() == currentUserId) { + amAdmin = true + } + } + + return amAdmin && adminCount == 1 + } + private fun BatchResponse.requireAllRequestsSuccessful(errorMessage: String) { val firstError = this.results.firstOrNull { it.code != 200 } require(firstError == null) { "$errorMessage: ${firstError!!.body}" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt new file mode 100644 index 0000000000..eb56e3f0df --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupAdminsViewModel.kt @@ -0,0 +1,295 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupInviteException +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.util.AvatarUtils + +/** + * Admin screen: + * - Shows admins + their promotion status + * - Lets you select admins with failed/sent promotions + * - Bottom tray: "Resend promotions" + * + * No removing members, no invites here. + */ +@HiltViewModel(assistedFactory = ManageGroupAdminsViewModel.Factory::class) +class ManageGroupAdminsViewModel @AssistedInject constructor( + @Assisted private val groupAddress: Address.Group, + @Assisted private val navigator: UINavigator, + @Assisted private val openPromoteMembers: Boolean, + @ApplicationContext private val context: Context, + storage: StorageProtocol, + private val configFactory: ConfigFactoryProtocol, + private val groupManager: GroupManagerV2, + private val recipientRepository: RecipientRepository, + avatarUtils: AvatarUtils, +) : BaseGroupMembersViewModel( + groupAddress = groupAddress, + context = context, + storage = storage, + configFactory = configFactory, + avatarUtils = avatarUtils, + recipientRepository = recipientRepository +) { + private val groupId = groupAddress.accountId + + /** + * One option for admins for now: "Promote members" + */ + private val optionsList: List by lazy { + listOf( + OptionsItem( + // use plural version of this string resource + name = context.resources.getQuantityString(R.plurals.promoteMember, 2, 2), + icon = R.drawable.ic_add_admin_custom, + onClick = ::navigateToPromoteMembers + ) + ) + } + + private val _mutableSelectedAdmins = MutableStateFlow(emptySet()) + val selectedAdmins: StateFlow> = _mutableSelectedAdmins + + private val footerCollapsed = MutableStateFlow(false) + + private val _uiState = MutableStateFlow(UiState(options = optionsList)) + val uiState: StateFlow = _uiState + + init { + // Build footer from selected admins + collapsed state + viewModelScope.launch { + kotlinx.coroutines.flow.combine( + selectedAdmins, + footerCollapsed, + ::buildFooterState + ).collect { footer -> + _uiState.update { it.copy(footer = footer) } + } + } + + if (openPromoteMembers) { + // Only runs once for this nav entry, so no loop on back + navigateToPromoteMembers() + } + } + + fun onAdminItemClicked(member: GroupMemberState) { + val newSet = _mutableSelectedAdmins.value.toHashSet() + if (!newSet.remove(member)) { + newSet.add(member) + } + _mutableSelectedAdmins.value = newSet + } + + fun onSearchFocusChanged(isFocused: Boolean) { + _uiState.update { it.copy(isSearchFocused = isFocused) } + } + + private fun navigateToPromoteMembers() { + viewModelScope.launch { + navigator.navigate( + destination = ConversationSettingsDestination.RoutePromoteMembers(groupAddress), + debounce = false + ) + } + } + + private fun setLoading(isLoading : Boolean){ + _uiState.update { it.copy(inProgress = isLoading) } + } + + /** + * Send promotions to all selected admins (explicit selection from caller). + */ + fun onSendPromotionsClicked(selectedAdmins: Set) { + sendPromotions(members = selectedAdmins, isRepromote = false) + } + + /** + * Resend promotions using locally selected admins. + * Used in the parent screen with admin list + */ + fun onResendPromotionsClicked() { + sendPromotions(isRepromote = true) + } + + private fun sendPromotions( + members: Set = selectedAdmins.value, + isRepromote: Boolean + ) { + if (members.isEmpty()) return + + val accountIds = members.map { it.accountId } + + val resendingText = context.resources.getQuantityString( + R.plurals.resendingPromotion, + accountIds.size, + accountIds.size + ) + + showToast(resendingText) + + performGroupOperationCore( + showLoading = false, + setLoading = ::setLoading, + errorMessage = { err -> + if (err is GroupInviteException) { + err.format(context, recipientRepository).toString() + } else { + null + } + } + ) { + removeSearchState(clearSelection = true) + + groupManager.promoteMember( + groupId, + accountIds, + isRepromote = isRepromote + ) + } + } + + fun removeSearchState(clearSelection: Boolean) { + onSearchFocusChanged(false) + onSearchQueryChanged("") + + if (clearSelection) { + clearSelection() + } + } + + fun clearSelection() { + _mutableSelectedAdmins.value = emptySet() + } + + fun toggleFooter() { + footerCollapsed.update { !it } + } + + private fun buildFooterState( + selected: Set, + isCollapsed: Boolean + ): CollapsibleFooterState { + val count = selected.size + val visible = count > 0 + + val title = + if (count == 0) GetString("") + else { + GetString( + context.resources.getQuantityString( + R.plurals.adminSelected, + count, + count + ) + ) + } + + val trayItems = listOf( + CollapsibleFooterItemData( + label = GetString( + context.resources.getQuantityString(R.plurals.resendPromotion, count, count) + ), + buttonLabel = GetString(context.getString(R.string.resend)), + isDanger = false, + onClick = { onResendPromotionsClicked() } + ) + ) + + return CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerActionTitle = title, + footerActionItems = trayItems + ) + } + + fun onCommand(command: Commands) { + when (command) { + is Commands.ToggleFooter -> toggleFooter() + is Commands.CloseFooter, + is Commands.ClearSelection -> clearSelection() + is Commands.SelfClick -> showToast(context.getString(R.string.adminStatusYou)) + is Commands.MemberClick -> onAdminItemClicked(command.member) + is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) + is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) + is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + } + } + + data class UiState( + val options: List = emptyList(), + + val inProgress: Boolean = false, + + // search UI state: + val searchQuery: String = "", + val isSearchFocused: Boolean = false, + + //Collapsible footer + val footer: CollapsibleFooterState = CollapsibleFooterState(), + ) + + data class CollapsibleFooterState( + val visible: Boolean = false, + val collapsed: Boolean = false, + val footerActionTitle: GetString = GetString(""), + val footerActionItems: List = emptyList() + ) + + data class OptionsItem( + val name: String, + @DrawableRes val icon: Int, + @StringRes val qaTag: Int? = null, + val onClick: () -> Unit + ) + + sealed interface Commands { + data object ToggleFooter : Commands + data object CloseFooter : Commands + data object ClearSelection : Commands + + data object SelfClick : Commands + + class RemoveSearchState(val clearSelection: Boolean) : Commands + data class SearchQueryChange(val query: String) : Commands + data class SearchFocusChange(val focus: Boolean) : Commands + + data class MemberClick(val member: GroupMemberState) : Commands + } + + @AssistedFactory + interface Factory { + fun create( + groupAddress: Address.Group, + navigator: UINavigator, + navigateToPromoteMembers: Boolean + ): ManageGroupAdminsViewModel + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt index 006a61dcd7..377a98cb0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -10,6 +10,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.getOrNull import org.session.libsession.database.StorageProtocol @@ -53,11 +55,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( ) : BaseGroupMembersViewModel(groupAddress, context, storage, configFactory, avatarUtils, recipientRepository) { private val groupId = groupAddress.accountId - // Output: The name of the group. This is the current name of the group, not the name being edited. - val groupName: StateFlow = groupInfo - .map { it?.first?.name.orEmpty() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "") - // Output: whether we should show the "add members" button val showAddMembers: StateFlow = groupInfo .map { it?.first?.isUserAdmin == true } @@ -87,7 +84,19 @@ class ManageGroupMembersViewModel @AssistedInject constructor( ) } - private val _uiState = MutableStateFlow(UiState(options = optionsList)) + private val adminOptionsList: List by lazy { + listOf( + OptionsItem( + // use plural version of this string resource + name = context.resources.getQuantityString(R.plurals.promoteMember,2,2), + icon = R.drawable.ic_add_admin_custom, + onClick = ::navigateToInviteContacts + ), + ) + } + + private val _uiState = + MutableStateFlow(UiState(options = optionsList, adminOptions = adminOptionsList)) val uiState: StateFlow = _uiState private val showRemoveMembersDialog = MutableStateFlow(false) @@ -143,18 +152,17 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } fun onSendInviteClicked(contacts: Set
, shareHistory : Boolean) { - _uiState.update { - it.copy( - ongoingAction = context.resources.getQuantityString( - R.plurals.groupInviteSending, - contacts.size, - contacts.size - ) - ) - } + val sendInviteText = context.resources.getQuantityString( + R.plurals.groupInviteSending, + contacts.size, + contacts.size + ) - performGroupOperation( + showToast(sendInviteText) + + performGroupOperationCore( showLoading = false, + setLoading = ::setLoading, errorMessage = { err -> if (err is GroupInviteException) { err.format(context, recipientRepository).toString() @@ -174,8 +182,9 @@ class ManageGroupMembersViewModel @AssistedInject constructor( fun onResendInviteClicked() { if (selectedMembers.value.isEmpty()) return - performGroupOperation( + performGroupOperationCore( showLoading = false, + setLoading = ::setLoading, errorMessage = { err -> if (err is GroupInviteException) { err.format(context, recipientRepository).toString() @@ -195,12 +204,15 @@ class ManageGroupMembersViewModel @AssistedInject constructor( removeSearchState(true) - _uiState.update { it -> - it.copy(error = context.resources.getQuantityString( - R.plurals.resendingInvite, - invites.size, - invites.size - )) + val errorText = context.resources.getQuantityString( + R.plurals.resendingInvite, + invites.size, + invites.size + ) + + // is it better move the invites list outside the operation? + withContext(Dispatchers.Main) { + showToast(errorText) // now safely on main thread } // Reinvite with per-member shareHistory @@ -220,22 +232,16 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } - fun onPromoteContact(memberSessionId: AccountId) { - performGroupOperation(showLoading = false) { - groupManager.promoteMember(groupId, listOf(memberSessionId), isRepromote = false) - } - } - fun onRemoveContact(removeMessages: Boolean) { - _uiState.update { it -> - it.copy(ongoingAction =context.resources.getQuantityString( - R.plurals.removingMember, - selectedMembers.value.size, - selectedMembers.value.size - )) - } + val removeText = context.resources.getQuantityString( + R.plurals.removingMember, + selectedMembers.value.size, + selectedMembers.value.size + ) + + showToast(removeText) - performGroupOperation(showLoading = false) { + performGroupOperationCore(showLoading = false, setLoading = ::setLoading) { val accountIdList = selectedMembers.value.map { it.accountId } removeSearchState(true) @@ -248,55 +254,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } - fun onResendPromotionClicked(memberSessionId: AccountId) { - performGroupOperation(showLoading = false) { - groupManager.promoteMember(groupId, listOf(memberSessionId), isRepromote = true) - } - } - - fun onDismissError() { - _uiState.update { it.copy(error = null) } - } - - /** - * Perform a group operation, such as inviting a member, removing a member. - * - * This is a helper function that encapsulates the common error handling and progress tracking. - */ - private fun performGroupOperation( - showLoading: Boolean = true, - errorMessage: ((Throwable) -> String?)? = null, - operation: suspend () -> Unit - ) { - viewModelScope.launch { - if (showLoading) { - _uiState.update { it.copy(inProgress = true) } - } - - // We need to use GlobalScope here because we don't want - // any group operation to be cancelled when the view model is cleared. - @Suppress("OPT_IN_USAGE") - val task = GlobalScope.async { - operation() - } - - try { - task.await() - } catch (e: Exception) { - _uiState.update { - it.copy( - error = errorMessage?.invoke(e) - ?: context.getString(R.string.errorUnknown) - ) - } - } finally { - if (showLoading) { - _uiState.update { it.copy(inProgress = false) } - } - } - } - } - fun clearSelection(){ _mutableSelectedMembers.value = emptySet() } @@ -305,14 +262,14 @@ class ManageGroupMembersViewModel @AssistedInject constructor( footerCollapsed.update { !it } } - fun onDismissResend() { - _uiState.update { it.copy(ongoingAction = null) } - } - private fun toggleRemoveMembersDialog(visible : Boolean){ showRemoveMembersDialog.value = visible } + private fun setLoading(isLoading : Boolean){ + _uiState.update { it.copy(inProgress = true) } + } + fun onCommand(command: Commands) { when (command) { is Commands.ShowRemoveMembersDialog -> toggleRemoveMembersDialog(true) @@ -327,10 +284,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( is Commands.ToggleFooter -> toggleFooter() - is Commands.DismissError -> onDismissError() - - is Commands.DismissResend -> onDismissResend() - is Commands.MemberClick -> onMemberItemClicked(command.member) is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) @@ -426,10 +379,9 @@ class ManageGroupMembersViewModel @AssistedInject constructor( data class UiState( val options : List = emptyList(), + val adminOptions : List = emptyList(), val inProgress: Boolean = false, - val error: String? = null, - val ongoingAction: String? = null, // search UI state: val searchQuery: String = "", @@ -467,10 +419,6 @@ class ManageGroupMembersViewModel @AssistedInject constructor( data object ShowRemoveMembersDialog : Commands data object DismissRemoveMembersDialog : Commands - data object DismissError : Commands - - data object DismissResend : Commands - data object ToggleFooter : Commands data object CloseFooter : Commands diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt new file mode 100644 index 0000000000..9cd0f408bd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/PromoteMembersViewModel.kt @@ -0,0 +1,251 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.util.AvatarUtils + +@HiltViewModel(assistedFactory = PromoteMembersViewModel.Factory::class) +class PromoteMembersViewModel @AssistedInject constructor( + @Assisted private val groupAddress: Address.Group, + @ApplicationContext private val context: Context, + storage: StorageProtocol, + private val configFactory: ConfigFactoryProtocol, + private val groupManager: GroupManagerV2, + private val recipientRepository: RecipientRepository, + avatarUtils: AvatarUtils, +) : BaseGroupMembersViewModel( + groupAddress = groupAddress, + context = context, + storage = storage, + configFactory = configFactory, + avatarUtils = avatarUtils, + recipientRepository = recipientRepository +) { + private val groupId = groupAddress.accountId + + private val _mutableSelectedMembers = MutableStateFlow(emptySet()) + val selectedMembers: StateFlow> = _mutableSelectedMembers + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState + + private val _footerCollapsed = MutableStateFlow(false) + + init { + viewModelScope.launch { + combine( + selectedMembers, + _footerCollapsed, + ::buildFooterState + ).collect { footer -> + _uiState.update { it.copy(footer = footer) } + } + } + + viewModelScope.launch { + selectedMembers + .map { selected -> buildPromoteDialogBody(selected) } + .collect { body -> + _uiState.update { it.copy(promoteDialogBody = body) } + } + } + } + + fun onMemberItemClicked(member: GroupMemberState) { + val newSet = _mutableSelectedMembers.value.toHashSet() + if (!newSet.remove(member)) { + newSet.add(member) + } + _mutableSelectedMembers.value = newSet + } + + fun onSearchFocusChanged(isFocused: Boolean) { + _uiState.update { it.copy(isSearchFocused = isFocused) } + } + + fun toggleFooter() { + _footerCollapsed.update { !it } + } + + fun removeSearchState(clearSelection: Boolean) { + onSearchFocusChanged(false) + onSearchQueryChanged("") + + if (clearSelection) { + clearSelection() + } + } + + fun clearSelection() { + _mutableSelectedMembers.value = emptySet() + } + + private fun buildFooterState( + selected: Set, + isCollapsed: Boolean + ): CollapsibleFooterState { + val count = selected.size + val visible = count > 0 + + val title = + if (count == 0) GetString("") + else { + GetString( + context.resources.getQuantityString( + R.plurals.memberSelected, + count, + count + ) + ) + } + + val footerAction = GetString( + context.resources.getQuantityString( + R.plurals.promoteMember, + count, count + ) + ) + + return CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerTitle = title, + footerActionLabel = footerAction + ) + } + + private fun buildPromoteDialogBody( + selected: Set + ): String { + val count = selected.size + val firstMember = selected.firstOrNull() + + val body: CharSequence = when (count) { + 1 -> { + Phrase.from(context, R.string.adminPromoteDescription) + .put(NAME_KEY, firstMember?.name) + .format() + } + + 2 -> { + val secondMember = selected.elementAtOrNull(1)?.name + Phrase.from(context, R.string.adminPromoteTwoDescription) + .put(NAME_KEY, firstMember?.name) + .put(OTHER_NAME_KEY, secondMember) + .format() + } + + 0 -> "" + else -> Phrase.from(context, R.string.adminPromoteMoreDescription) + .put(NAME_KEY, firstMember?.name) + .put(COUNT_KEY, count - 1) + .format() + } + + return body.toString() + } + + fun onCommand(command: Commands) { + when (command) { + is Commands.ShowPromoteDialog -> { + _uiState.update { it.copy(showPromoteDialog = true) } + } + + is Commands.DismissPromoteDialog -> { + _uiState.update { it.copy(showPromoteDialog = false) } + } + + is Commands.ShowConfirmDialog -> { + _uiState.update { it.copy(showConfirmDialog = true) } + } + + is Commands.DismissConfirmDialog -> { + _uiState.update { it.copy(showConfirmDialog = false) } + } + + is Commands.ToggleFooter -> toggleFooter() + + is Commands.CloseFooter, + is Commands.ClearSelection -> clearSelection() + + is Commands.MemberClick -> onMemberItemClicked(command.member) + + is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) + + is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) + + is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + } + } + + sealed interface Commands { + data object ShowPromoteDialog : Commands + data object DismissPromoteDialog : Commands + + data object ShowConfirmDialog : Commands + data object DismissConfirmDialog : Commands + + data object ToggleFooter : Commands + data object CloseFooter : Commands + data object ClearSelection : Commands + + data class RemoveSearchState(val clearSelection: Boolean) : Commands + data class SearchQueryChange(val query: String) : Commands + data class SearchFocusChange(val focus: Boolean) : Commands + + data class MemberClick(val member: GroupMemberState) : Commands + } + + data class UiState( + // search UI state: + val searchQuery: String = "", + val isSearchFocused: Boolean = false, + + val showConfirmDialog: Boolean = false, + + val showPromoteDialog: Boolean = false, + val promoteDialogBody: String = "", + + //Collapsible footer + val footer: CollapsibleFooterState = CollapsibleFooterState() + ) + + data class CollapsibleFooterState( + val visible: Boolean = false, + val collapsed: Boolean = false, + val footerTitle: GetString = GetString(""), + val footerActionLabel: GetString = GetString("") + ) + + + @AssistedFactory + interface Factory { + fun create( + groupAddress: Address.Group, + ): PromoteMembersViewModel + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt index 09428ee146..2c11a16dbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/Components.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem +import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.groups.InviteMembersViewModel import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.DialogButtonData @@ -139,7 +140,8 @@ fun RadioMemberItem( showProBadge: Boolean, modifier: Modifier = Modifier, subtitle: String? = null, - subtitleColor: Color = LocalColors.current.textSecondary + subtitleColor: Color = LocalColors.current.textSecondary, + showRadioButton: Boolean = true ) { MemberItem( address = address, @@ -152,10 +154,12 @@ fun RadioMemberItem( showProBadge = showProBadge, modifier = modifier ) { - RadioButtonIndicator( - selected = selected, - enabled = enabled - ) + if (showRadioButton) { + RadioButtonIndicator( + selected = selected, + enabled = enabled + ) + } } } @@ -239,6 +243,33 @@ fun InviteMembersDialog( ) } +@Composable +fun ManageMemberItem( + member: GroupMemberState, + onClick: (address: Address) -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false +) { + RadioMemberItem( + address = Address.fromSerialized(member.accountId.hexString), + title = member.name, + subtitle = member.statusLabel, + subtitleColor = if (member.highlightStatus) { + LocalColors.current.danger + } else { + LocalColors.current.textSecondary + }, + showAsAdmin = member.showAsAdmin, + showProBadge = member.showProBadge, + avatarUIData = member.avatarUIData, + onClick = onClick, + modifier = modifier, + enabled = true, + selected = selected, + showRadioButton = !member.isSelf + ) +} + @Preview @Composable fun PreviewMemberList() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt index 7b69716593..af8317d772 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/GroupMembersScreen.kt @@ -138,6 +138,7 @@ private fun EditGroupPreview() { ) ) ), + isSelf = false ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), @@ -160,6 +161,7 @@ private fun EditGroupPreview() { ) ) ), + isSelf = false ) val threeMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), @@ -182,6 +184,7 @@ private fun EditGroupPreview() { ) ) ), + isSelf = false ) GroupMembers( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt new file mode 100644 index 0000000000..d1e0f2953d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupAdminsScreen.kt @@ -0,0 +1,291 @@ +package org.thoughtcrime.securesms.groups.compose + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import network.loki.messenger.R +import org.thoughtcrime.securesms.groups.GroupMemberState +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel +import org.thoughtcrime.securesms.groups.ManageGroupAdminsViewModel.Commands.* +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.CollapsibleFooterAction +import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.ItemButton +import org.thoughtcrime.securesms.ui.LoadingDialog +import org.thoughtcrime.securesms.ui.SearchBarWithClose +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.getCellBottomShape +import org.thoughtcrime.securesms.ui.getCellTopShape +import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors + +@Composable +fun ManageGroupAdminsScreen( + viewModel: ManageGroupAdminsViewModel, + onBack: () -> Unit, +) { + ManageAdmins( + onBack = onBack, + uiState = viewModel.uiState.collectAsState().value, + admins = viewModel.adminMembers.collectAsState().value, + selectedMembers = viewModel.selectedAdmins.collectAsState().value, + searchQuery = viewModel.searchQuery.collectAsState().value, + sendCommand = viewModel::onCommand, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManageAdmins( + onBack: () -> Unit, + uiState: ManageGroupAdminsViewModel.UiState, + searchQuery: String, + admins: List, + selectedMembers: Set = emptySet(), + sendCommand: (command: ManageGroupAdminsViewModel.Commands) -> Unit, +) { + + val searchFocused = uiState.isSearchFocused + + val handleBack: () -> Unit = { + when { + searchFocused -> sendCommand(RemoveSearchState(false)) + else -> onBack() + } + } + + // Intercept system back + BackHandler(enabled = true) { handleBack() } + + Scaffold( + topBar = { + BackAppBar( + title = stringResource(id = R.string.manageAdmins), + onBack = handleBack, + ) + }, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .imePadding() + ) { + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = uiState.footer.footerActionTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, + items = uiState.footer.footerActionItems + ), + onCollapsedClicked = { sendCommand(ToggleFooter) }, + onClosedClicked = { sendCommand(CloseFooter) } + ) + } + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + Text( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + text = LocalResources.current.getString(R.string.adminCannotBeDemoted), + textAlign = TextAlign.Center, + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + + AnimatedVisibility( + // show only when add-members is enabled AND search is not focused + visible = !searchFocused, + enter = fadeIn(animationSpec = tween(150)) + + expandVertically( + animationSpec = tween(200), + expandFrom = Alignment.Top + ), + exit = fadeOut(animationSpec = tween(150)) + + shrinkVertically( + animationSpec = tween(180), + shrinkTowards = Alignment.Top + ) + ) { + Column { + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + Cell( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = LocalDimensions.current.smallSpacing), + ) { + Column { + uiState.options.forEachIndexed { index, option -> + ItemButton( + modifier = Modifier.qaTag(option.qaTag), + text = annotatedStringResource(option.name), + iconRes = option.icon, + shape = when (index) { + 0 -> getCellTopShape() + uiState.options.lastIndex -> getCellBottomShape() + else -> RectangleShape + }, + onClick = option.onClick, + ) + + if (index != uiState.options.lastIndex) Divider() + } + } + } + } + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + if (!searchFocused) { + Text( + modifier = Modifier.padding( + start = LocalDimensions.current.mediumSpacing, + bottom = LocalDimensions.current.smallSpacing + ), + text = LocalResources.current.getString(R.string.admins), + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + } + + SearchBarWithClose( + query = searchQuery, + onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, + onClear = { sendCommand(SearchQueryChange("")) }, + placeholder = if (searchFocused) "" else LocalResources.current.getString(R.string.search), + enabled = true, + isFocused = searchFocused, + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // List of members + LazyColumn( + modifier = Modifier + .weight(1f) + .imePadding() + ) { + items(admins) { member -> + // Each member's view + ManageMemberItem( + modifier = Modifier.fillMaxWidth(), + member = member, + onClick = { + if (member.isSelf) sendCommand(SelfClick) + else sendCommand(MemberClick(member)) + }, + selected = member in selectedMembers + ) + } + + item { + Spacer( + modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) + ) + } + } + } + } + + if (uiState.inProgress) { + LoadingDialog() + } +} + + +@Preview +@Composable +private fun PreviewManageAdmins( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ManageAdmins( + onBack = {}, + admins = listOf(), + searchQuery = "", + selectedMembers = emptySet(), + sendCommand = {}, + uiState = ManageGroupAdminsViewModel.UiState( + options = emptyList(), + footer = ManageGroupAdminsViewModel.CollapsibleFooterState( + visible = false, + collapsed = true, + footerActionTitle = GetString("2 Admins Selected"), + footerActionItems = listOf( + CollapsibleFooterItemData( + label = GetString("Resend"), + buttonLabel = GetString("1"), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Remove"), + buttonLabel = GetString("1"), + isDanger = true, + onClick = { } + ) + ) + )), + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt index 6c8b49ea59..17e79fd96c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import network.loki.messenger.R import network.loki.messenger.libsession_util.util.GroupMember -import org.session.libsession.utilities.Address import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.groups.GroupMemberState import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel @@ -112,8 +111,6 @@ fun ManageMembers( ) { val searchFocused = uiState.isSearchFocused - val showingError = uiState.error - val showingOngoingAction = uiState.ongoingAction val handleBack: () -> Unit = { when { @@ -262,7 +259,7 @@ fun ManageMembers( } if (uiState.removeMembersDialog.visible) { - ShowRemoveMembersDialog( + RemoveMembersDialog( state = uiState.removeMembersDialog, sendCommand = sendCommand ) @@ -271,51 +268,10 @@ fun ManageMembers( if (uiState.inProgress) { LoadingDialog() } - - val context = LocalContext.current - - LaunchedEffect(showingError) { - if (showingError != null) { - Toast.makeText(context, showingError, Toast.LENGTH_SHORT).show() - sendCommand(DismissError) - } - } - LaunchedEffect(showingOngoingAction) { - if (showingOngoingAction != null) { - Toast.makeText(context, showingOngoingAction, Toast.LENGTH_SHORT).show() - sendCommand(DismissResend) - } - } -} - -@Composable -fun ManageMemberItem( - member: GroupMemberState, - onClick: (address: Address) -> Unit, - modifier: Modifier = Modifier, - selected: Boolean = false -) { - RadioMemberItem( - address = Address.fromSerialized(member.accountId.hexString), - title = member.name, - subtitle = member.statusLabel, - subtitleColor = if (member.highlightStatus) { - LocalColors.current.danger - } else { - LocalColors.current.textSecondary - }, - showAsAdmin = member.showAsAdmin, - showProBadge = member.showProBadge, - avatarUIData = member.avatarUIData, - onClick = onClick, - modifier = modifier, - enabled = true, - selected = selected - ) } @Composable -fun ShowRemoveMembersDialog( +fun RemoveMembersDialog( state: ManageGroupMembersViewModel.RemoveMembersDialogState, modifier: Modifier = Modifier, sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit @@ -414,6 +370,7 @@ private fun EditGroupPreviewSheet() { showProBadge = true, clickable = true, statusLabel = "Invited", + isSelf = false ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), @@ -435,7 +392,8 @@ private fun EditGroupPreviewSheet() { showAsAdmin = true, showProBadge = true, clickable = true, - statusLabel = "Promotion failed" + statusLabel = "Promotion failed", + isSelf = false ) val threeMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), @@ -457,7 +415,8 @@ private fun EditGroupPreviewSheet() { showAsAdmin = false, showProBadge = false, clickable = true, - statusLabel = "" + statusLabel = "", + isSelf = true ) val (_, _) = remember { mutableStateOf(null) } @@ -510,6 +469,7 @@ private fun EditGroupEditNamePreview( showProBadge = true, clickable = true, statusLabel = "Invited", + isSelf = false ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), @@ -531,7 +491,8 @@ private fun EditGroupEditNamePreview( showAsAdmin = true, showProBadge = true, clickable = true, - statusLabel = "Promotion failed" + statusLabel = "Promotion failed", + isSelf = false ) val threeMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), @@ -553,7 +514,8 @@ private fun EditGroupEditNamePreview( showAsAdmin = false, showProBadge = false, clickable = true, - statusLabel = "" + statusLabel = "", + isSelf = false ) ManageMembers( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt new file mode 100644 index 0000000000..dd5335825c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/PromoteMembersScreen.kt @@ -0,0 +1,288 @@ +package org.thoughtcrime.securesms.groups.compose + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import network.loki.messenger.R +import org.thoughtcrime.securesms.groups.GroupMemberState +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.CloseFooter +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.DismissConfirmDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.DismissPromoteDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.MemberClick +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.SearchFocusChange +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.SearchQueryChange +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ShowConfirmDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ShowPromoteDialog +import org.thoughtcrime.securesms.groups.PromoteMembersViewModel.Commands.ToggleFooter +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.CollapsibleFooterAction +import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.DialogButtonData +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.SearchBarWithClose +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType + +@Composable +fun PromoteMembersScreen( + viewModel: PromoteMembersViewModel, + onBack: () -> Unit, + onPromoteClicked: (Set) -> Unit +) { + val uiState = viewModel.uiState.collectAsState().value + val searchQuery = viewModel.searchQuery.collectAsState().value + val hasActiveMembers = viewModel.hasActiveMembers.collectAsState().value + val members = viewModel.activeMembers.collectAsState().value + val selectedMembers = viewModel.selectedMembers.collectAsState().value + + PromoteMembers( + onBack = onBack, + uiState = uiState, + searchQuery = searchQuery, + sendCommand = viewModel::onCommand, + members = members, + selectedMembers = selectedMembers, + hasActiveMembers = hasActiveMembers, + onPromoteClicked = onPromoteClicked + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PromoteMembers( + onBack: () -> Unit, + uiState: PromoteMembersViewModel.UiState, + searchQuery: String, + sendCommand: (command: Commands) -> Unit, + members: List, + selectedMembers: Set = emptySet(), + hasActiveMembers: Boolean = false, + onPromoteClicked: (Set) -> Unit +) { + val searchFocused = uiState.isSearchFocused + + val handleBack: () -> Unit = { + when { + searchFocused -> sendCommand(Commands.RemoveSearchState(false)) + else -> onBack() + } + } + + // Intercept system back + BackHandler(enabled = true) { handleBack() } + + + Scaffold( + topBar = { + BackAppBar( + title = pluralStringResource(id = R.plurals.promoteMember, 2), + onBack = handleBack, + ) + }, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .imePadding() + ) { + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = uiState.footer.footerTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, + items = listOf( + CollapsibleFooterItemData( + label = uiState.footer.footerActionLabel, + buttonLabel = GetString(LocalResources.current.getString(R.string.promote)), + isDanger = false, + onClick = { sendCommand(ShowPromoteDialog) } + ) + ) + ), + onCollapsedClicked = { sendCommand(ToggleFooter) }, + onClosedClicked = { sendCommand(CloseFooter) } + ) + } + }, + contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + ) { + Text( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + text = LocalResources.current.getString(if (!hasActiveMembers) R.string.noNonAdminsInGroup else R.string.adminCannotBeDemoted), + textAlign = TextAlign.Center, + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + + if (hasActiveMembers) { + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + SearchBarWithClose( + query = searchQuery, + onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, + onClear = { sendCommand(SearchQueryChange("")) }, + placeholder = if (searchFocused) "" else LocalResources.current.getString(R.string.search), + enabled = true, + isFocused = searchFocused, + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) } + ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + + // List of members + LazyColumn( + modifier = Modifier + .weight(1f) + .imePadding() + ) { + items(members) { member -> + // Each member's view + ManageMemberItem( + modifier = Modifier.fillMaxWidth(), + member = member, + onClick = { sendCommand(MemberClick(member)) }, + selected = member in selectedMembers + ) + } + + item { + Spacer( + modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) + ) + } + } + } + } + } + + if (uiState.showConfirmDialog) { + ConfirmDialog( + sendCommand = sendCommand, + onConfirmClicked = { onPromoteClicked(selectedMembers) }) + } + + if (uiState.showPromoteDialog) { + PromotionDialog(sendCommand = sendCommand, bodyText = uiState.promoteDialogBody) + } +} + +@Composable +fun ConfirmDialog( + modifier: Modifier = Modifier, + onConfirmClicked: () -> Unit, + sendCommand: (Commands) -> Unit +) { + AlertDialog( + modifier = modifier, + onDismissRequest = { + // hide dialog + sendCommand(DismissConfirmDialog) + }, + title = annotatedStringResource(R.string.confirmPromotion), + text = annotatedStringResource(R.string.confirmPromotionDescription), + buttons = listOf( + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + sendCommand(DismissConfirmDialog) + } + ), + DialogButtonData( + text = GetString(stringResource(id = R.string.confirm)), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + sendCommand(DismissConfirmDialog) + onConfirmClicked() + } + ) + ) + ) +} + +@Composable +fun PromotionDialog( + modifier: Modifier = Modifier, + sendCommand: (Commands) -> Unit, + bodyText: String +) { + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(DismissPromoteDialog) + }, + title = stringResource(R.string.promote), + text = bodyText, + showCloseButton = true, + content = { + Text( + modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing), + text = LocalResources.current.getString(R.string.promoteAdminsWarning), + style = LocalType.current.small, + color = LocalColors.current.warning, + textAlign = TextAlign.Center + ) + }, + buttons = listOf( + DialogButtonData( + text = GetString(stringResource(id = R.string.promote)), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + sendCommand(DismissPromoteDialog) + sendCommand(ShowConfirmDialog) + + } + ), + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + sendCommand(DismissPromoteDialog) + } + ) + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index 99e8af2de3..8bc00bf525 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -75,6 +75,8 @@ data class SimpleDialogData( val message: CharSequence, val positiveText: String? = null, val positiveStyleDanger: Boolean = true, + + val negativeStyleDanger: Boolean = false, val showXIcon: Boolean = false, val negativeText: String? = null, val positiveQaTag: String? = null, @@ -245,8 +247,8 @@ fun AlertDialogContent( color = it.color, enabled = it.enabled ) { - it.onClick() if (it.dismissOnClick) onDismissRequest() + it.onClick() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt index 8aa4c05bc3..b16d1ca55e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt @@ -18,10 +18,11 @@ class UINavigator () { suspend fun navigate( destination: T, - navOptions: NavOptionsBuilder.() -> Unit = {} + navOptions: NavOptionsBuilder.() -> Unit = {}, + debounce : Boolean = true // For when intentionally chaining navigations ) { val currentTime = System.currentTimeMillis() - if (currentTime - lastNavigationTime > navigationDebounceTime) { + if (!debounce || currentTime - lastNavigationTime > navigationDebounceTime) { lastNavigationTime = currentTime _navigationActions.send(NavigationAction.Navigate( destination = destination, diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index c977a454df..2695268dcd 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -149,6 +149,7 @@ delete-group-confirm-button delete-group-cancel-button leave-group-confirm-button + add-admin-button leave-group-cancel-button clear-all-messages-confirm-button clear-all-messages-cancel-button