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 59d13bcd94..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 @@ -6,12 +6,15 @@ import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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 @@ -28,14 +31,22 @@ import org.thoughtcrime.securesms.conversation.v2.settings.notification.Notifica import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsViewModel import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel import org.thoughtcrime.securesms.groups.GroupMembersViewModel -import org.thoughtcrime.securesms.groups.SelectContactsViewModel +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 import org.thoughtcrime.securesms.media.MediaOverviewViewModel import org.thoughtcrime.securesms.ui.NavigationAction import org.thoughtcrime.securesms.ui.ObserveAsEvents +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.horizontalSlideComposable @@ -65,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( @@ -94,6 +129,18 @@ sealed interface ConversationSettingsDestination: Parcelable { data class RouteInviteToCommunity( val communityUrl: String ): ConversationSettingsDestination + + @Serializable + @Parcelize + data class RouteInviteAccountIdToGroup private constructor( + private val address: String, + val excludingAccountIDs: List + ): ConversationSettingsDestination { + constructor(groupAddress: Address.Group, excludingAccountIDs: List) + : this(groupAddress.address, excludingAccountIDs) + + val groupAddress: Address.Group get() = Address.Group(AccountId(address)) + } } @SuppressLint("RestrictedApi") @@ -195,13 +242,31 @@ 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() val viewModel = - hiltViewModel { factory -> + hiltViewModel { factory -> factory.create( + groupAddress = data.groupAddress, excludingAccountIDs = data.excludingAccountIDs.map(Address::fromSerialized).toSet() ) } @@ -216,23 +281,22 @@ fun ConversationSettingsNavHost( InviteContactsScreen( viewModel = viewModel, - onDoneClicked = dropUnlessResumed { + onDoneClicked = { shareHistory -> //send invites from the manage group screen - manageGroupMembersViewModel.onContactSelected(viewModel.currentSelected) - + manageGroupMembersViewModel.onSendInviteClicked(viewModel.currentSelected, shareHistory) handleBack() }, onBack = dropUnlessResumed { handleBack() }, - banner = {} + forCommunity = false ) } // Invite Contacts to community horizontalSlideComposable { backStackEntry -> val viewModel = - hiltViewModel { factory -> + hiltViewModel { factory -> factory.create() } @@ -252,10 +316,94 @@ fun ConversationSettingsNavHost( // clear selected contacts viewModel.clearSelection() + handleBack() + }, + onBack = dropUnlessResumed { + handleBack() }, + forCommunity = true + ) + } + + // Invite contacts using Account ID + horizontalSlideComposable { backStackEntry -> + val data: RouteInviteAccountIdToGroup = backStackEntry.toRoute() + + val viewModel = + hiltViewModel { factory -> + factory.create( + groupAddress = data.groupAddress, + excludingAccountIDs = data.excludingAccountIDs.map(Address::fromSerialized).toSet() + ) + } + + val newMessageViewModel = hiltViewModel() + val uiState by newMessageViewModel.state.collectAsState(State()) + + // grab a hold of manage group's VM + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry( + RouteManageMembers(data.groupAddress) + ) + } + + val manageGroupMembersViewModel: ManageGroupMembersViewModel = hiltViewModel(parentEntry) + + LaunchedEffect(Unit) { + newMessageViewModel.success.collect { success -> + viewModel.sendCommand( + InviteMembersViewModel.Commands.HandleAccountId( + address = success.address + ) + ) + } + } + + InviteAccountIdScreen( + viewModel = viewModel, + state = uiState, + qrErrors = newMessageViewModel.qrErrors, + callbacks = newMessageViewModel, + onBack = { handleBack() }, + onHelp = { newMessageViewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) }, + onDismissHelpDialog = { + newMessageViewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) + }, + onSendInvite = { shareHistory -> + manageGroupMembersViewModel.onCommand( + ManageGroupMembersViewModel.Commands.SendInvites( + address = viewModel.currentSelected, + shareHistory = shareHistory + ) + ) + handleBack() + }, + ) + } + + // 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() + } ) } 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/InviteMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt new file mode 100644 index 0000000000..184a5f0223 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/InviteMembersViewModel.kt @@ -0,0 +1,228 @@ +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.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import network.loki.messenger.R +import org.session.libsession.utilities.Address +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.dependencies.ConfigFactory +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.util.AvatarUtils + +@HiltViewModel(assistedFactory = InviteMembersViewModel.Factory::class) +class InviteMembersViewModel @AssistedInject constructor( + @Assisted private val groupAddress: Address.Group?, + @Assisted private val excludingAccountIDs: Set
, + @param:ApplicationContext private val context: Context, + configFactory: ConfigFactory, + avatarUtils: AvatarUtils, + proStatusManager: ProStatusManager, + recipientRepository: RecipientRepository, +) : SelectContactsViewModel( + configFactory = configFactory, + excludingAccountIDs = excludingAccountIDs, + contactFiltering = SelectContactsViewModel.Factory.defaultFiltering, + avatarUtils = avatarUtils, + proStatusManager = proStatusManager, + recipientRepository = recipientRepository, + context = context +) { + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState + + private val footerCollapsed = MutableStateFlow(false) + private val showInviteContactsDialog = MutableStateFlow(false) + + init { + viewModelScope.launch { + combine(selectedContacts, footerCollapsed) { selected, isCollapsed -> + buildFooterState(selected, isCollapsed) + }.collect { footer -> + _uiState.update { it.copy(footer = footer) } + } + } + + viewModelScope.launch { + combine(selectedContacts, showInviteContactsDialog) { selected, showDialog -> + buildInviteContactsDialogState(showDialog, selected) + }.collect { state -> + _uiState.update { it.copy(inviteContactsDialog = state) } + } + } + } + + 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.contactSelected, count, count) + ) + + return CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerActionTitle = title + ) + } + + private fun buildInviteContactsDialogState( + visible: Boolean, + selected: Set, + ): InviteContactsDialogState { + val count = selected.size + val firstMember = selected.firstOrNull() + + val body: CharSequence = when (count) { + 1 -> { + if (firstMember != null && firstMember.name.isNotEmpty()) { + Phrase.from(context, R.string.membersInviteShareDescription) + .put(NAME_KEY, firstMember?.name) + .format() + } else { + // TODO: Need to add String in Crowdin + context.getString(R.string.membersInviteShareDescription) + } + } + 2 -> { + val secondMember = selected.elementAtOrNull(1)?.name + Phrase.from(context, R.string.membersInviteShareDescriptionTwo) + .put(NAME_KEY, firstMember?.name) + .put(OTHER_NAME_KEY, secondMember) + .format() + } + + 0 -> "" + else -> Phrase.from(context, R.string.membersInviteShareDescriptionMultiple) + .put(NAME_KEY, firstMember?.name) + .put(COUNT_KEY, count - 1) + .format() + } + + val inviteText = + context.resources.getQuantityString(R.plurals.membersInviteSend, count, count) + + return InviteContactsDialogState( + visible = visible, + inviteContactsBody = body, + inviteText = inviteText + ) + } + + fun toggleFooter() { + footerCollapsed.update { !it } + } + + fun onSearchFocusChanged(isFocused: Boolean) { + _uiState.update { it.copy(isSearchFocused = isFocused) } + } + + fun toggleInviteContactsDialog(visible: Boolean) { + showInviteContactsDialog.value = visible + } + + fun removeSearchState(clearSelection: Boolean) { + onSearchFocusChanged(false) + onSearchQueryChanged("") + + if (clearSelection) { + clearSelection() + } + } + + fun sendCommand(command: Commands) { + when (command) { + is Commands.ToggleFooter -> toggleFooter() + + is Commands.CloseFooter, + Commands.ClearSelection -> clearSelection() + + is Commands.ContactItemClick -> onContactItemClicked(command.address) + + is Commands.HandleAccountId -> { + setManuallySelectedAddress(command.address) + toggleInviteContactsDialog(true) + } + + is Commands.DismissSendInviteDialog -> toggleInviteContactsDialog(false) + + is Commands.ShowSendInviteDialog -> toggleInviteContactsDialog(true) + + is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) + + is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + + is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) + } + } + + sealed interface Commands { + data object ToggleFooter : Commands + + data object CloseFooter : Commands + + data object ShowSendInviteDialog : Commands + + data object DismissSendInviteDialog : Commands + + data object ClearSelection : Commands + + data class HandleAccountId(val address : Address) : Commands + + data class ContactItemClick(val address: Address) : Commands + + data class SearchFocusChange(val focus: Boolean) : Commands + + data class SearchQueryChange(val query: String) : Commands + + data class RemoveSearchState(val clearSelection: Boolean) : Commands + } + + + data class UiState( + val isSearchFocused: Boolean = false, + val ongoingAction: String? = null, + + val inviteContactsDialog: InviteContactsDialogState = InviteContactsDialogState(), + val footer: CollapsibleFooterState = CollapsibleFooterState() + ) + + data class InviteContactsDialogState( + val visible: Boolean = false, + val inviteContactsBody: CharSequence = "", + val inviteText: String = "", + ) + + data class CollapsibleFooterState( + val visible: Boolean = false, + val collapsed: Boolean = false, + val footerActionTitle: GetString = GetString("") + ) + + @AssistedFactory + interface Factory { + fun create( + groupAddress: Address.Group? = null, + excludingAccountIDs: Set
= emptySet(), + ): InviteMembersViewModel + } +} \ No newline at end of file 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 e40099c29d..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,19 +10,18 @@ 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 import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach 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 @@ -56,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 } @@ -80,19 +74,29 @@ class ManageGroupMembersViewModel @AssistedInject constructor( OptionsItem( name = context.getString(R.string.membersInvite), icon = R.drawable.ic_user_round_plus, - onClick = ::navigateInviteContacts + onClick = ::navigateToInviteContacts ), OptionsItem( name = context.getString(R.string.accountIdOrOnsInvite), icon = R.drawable.ic_user_round_search, - onClick = { - // TODO: Add navigation - } + onClick = ::navigateToInviteAccountId ) ) } - 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) @@ -125,7 +129,7 @@ class ManageGroupMembersViewModel @AssistedInject constructor( _uiState.update { it.copy(isSearchFocused = isFocused) } } - private fun navigateInviteContacts() { + private fun navigateToInviteContacts() { viewModelScope.launch { navigator.navigate( ConversationSettingsDestination.RouteInviteToGroup( @@ -136,9 +140,29 @@ class ManageGroupMembersViewModel @AssistedInject constructor( } } - fun onContactSelected(contacts: Set
) { - performGroupOperation( + private fun navigateToInviteAccountId(){ + viewModelScope.launch { + navigator.navigate( + ConversationSettingsDestination.RouteInviteAccountIdToGroup( + groupAddress, + excludingAccountIDsFromContactSelection.toList() + ) + ) + } + } + + fun onSendInviteClicked(contacts: Set
, shareHistory : Boolean) { + val sendInviteText = context.resources.getQuantityString( + R.plurals.groupInviteSending, + contacts.size, + contacts.size + ) + + showToast(sendInviteText) + + performGroupOperationCore( showLoading = false, + setLoading = ::setLoading, errorMessage = { err -> if (err is GroupInviteException) { err.format(context, recipientRepository).toString() @@ -150,7 +174,7 @@ class ManageGroupMembersViewModel @AssistedInject constructor( groupManager.inviteMembers( groupId, contacts.map { AccountId(it.toString()) }.toList(), - shareHistory = false, + shareHistory = shareHistory, isReinvite = false, ) } @@ -158,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() @@ -179,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 @@ -204,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) @@ -232,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() } @@ -289,19 +262,19 @@ 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 toggleRemoveDialog(visible : Boolean){ - showRemoveMembersDialog.value = visible + private fun setLoading(isLoading : Boolean){ + _uiState.update { it.copy(inProgress = true) } } fun onCommand(command: Commands) { when (command) { - is Commands.ShowRemoveDialog -> toggleRemoveDialog(true) + is Commands.ShowRemoveMembersDialog -> toggleRemoveMembersDialog(true) - is Commands.DismissRemoveDialog -> toggleRemoveDialog(false) + is Commands.DismissRemoveMembersDialog -> toggleRemoveMembersDialog(false) is Commands.RemoveMembers -> onRemoveContact(command.removeMessages) @@ -311,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) @@ -322,6 +291,8 @@ class ManageGroupMembersViewModel @AssistedInject constructor( is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + + is Commands.SendInvites -> onSendInviteClicked(command.address, command.shareHistory) } } @@ -394,7 +365,7 @@ class ManageGroupMembersViewModel @AssistedInject constructor( ), buttonLabel = GetString(context.getString(R.string.remove)), isDanger = true, - onClick = { onCommand(Commands.ShowRemoveDialog) } + onClick = { onCommand(Commands.ShowRemoveMembersDialog) } ) ) @@ -408,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 = "", @@ -446,12 +416,8 @@ class ManageGroupMembersViewModel @AssistedInject constructor( ) sealed interface Commands { - data object ShowRemoveDialog : Commands - data object DismissRemoveDialog : Commands - - data object DismissError : Commands - - data object DismissResend : Commands + data object ShowRemoveMembersDialog : Commands + data object DismissRemoveMembersDialog : Commands data object ToggleFooter : Commands @@ -459,6 +425,8 @@ class ManageGroupMembersViewModel @AssistedInject constructor( data object ClearSelection : Commands + data class SendInvites(val address : Set
, val shareHistory: Boolean) : Commands + data class RemoveSearchState(val clearSelection : Boolean) : Commands data class SearchQueryChange(val query : String) : 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/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt index 25a6c5f18b..49cf31e28d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt @@ -17,21 +17,18 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext -import network.loki.messenger.R import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.home.search.searchName import org.thoughtcrime.securesms.pro.ProStatusManager -import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUtils @@ -50,7 +47,7 @@ open class SelectContactsViewModel @AssistedInject constructor( private val mutableSearchQuery = MutableStateFlow("") // Input: The selected contact account IDs - private val mutableSelectedContactAccountIDs = MutableStateFlow(emptySet
()) + private val mutableSelectedContacts = MutableStateFlow(emptySet()) // Input: The manually added items to select from. This will be combined (and deduped) with the contacts // the user has. This is useful for selecting contacts that are not in the user's contacts list. @@ -65,38 +62,20 @@ open class SelectContactsViewModel @AssistedInject constructor( val contacts: StateFlow> = combine( contactsFlow, mutableSearchQuery.debounce(100L), - mutableSelectedContactAccountIDs, + mutableSelectedContacts, ::filterContacts ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) - val hasContacts: StateFlow = contactsFlow + val hasContacts: StateFlow = contactsFlow .map { it.isNotEmpty() } - .stateIn(viewModelScope, SharingStarted.Eagerly, null) + .stateIn(viewModelScope, SharingStarted.Eagerly, false) - // Output + // Output: to be used by VMs extending this base VM + val selectedContacts: StateFlow> = mutableSelectedContacts + + // Output : snapshot helper val currentSelected: Set
- get() = mutableSelectedContactAccountIDs.value - - private val footerCollapsed = MutableStateFlow(false) - - val collapsibleFooterState: StateFlow = - combine(mutableSelectedContactAccountIDs, footerCollapsed) { selected, isCollapsed -> - val count = selected.size - val visible = count > 0 - val title = if (count == 0) GetString("") - else GetString( - context.resources.getQuantityString(R.plurals.contactSelected, count, count) - ) - - CollapsibleFooterState( - visible = visible, - // auto-expand when nothing is selected, otherwise keep user's choice - collapsed = if (!visible) false else isCollapsed, - footerActionTitle = title - ) - } - .distinctUntilChanged() - .stateIn(viewModelScope, SharingStarted.Eagerly, CollapsibleFooterState()) + get() = mutableSelectedContacts.value.map { it.address }.toSet() @OptIn(ExperimentalCoroutinesApi::class) private fun observeContacts() = (configFactory.configUpdateNotifications as Flow) @@ -127,9 +106,10 @@ open class SelectContactsViewModel @AssistedInject constructor( private fun filterContacts( contacts: Collection, query: String, - selectedAccountIDs: Set
+ selectedContacts: Set ): List { val items = mutableListOf() + val selectedAddresses = selectedContacts.asSequence().map { it.address }.toSet() for (contact in contacts) { if (query.isBlank() || contact.searchName.contains(query, ignoreCase = true)) { val avatarData = avatarUtils.getUIDataFromRecipient(contact) @@ -138,7 +118,7 @@ open class SelectContactsViewModel @AssistedInject constructor( name = contact.searchName, address = contact.address, avatarUIData = avatarData, - selected = selectedAccountIDs.contains(contact.address), + selected = selectedAddresses.contains(contact.address), showProBadge = contact.shouldShowProBadge ) ) @@ -151,36 +131,38 @@ open class SelectContactsViewModel @AssistedInject constructor( mutableManuallyAddedContacts.value = accountIDs } + // Used when getting results from a QR or AccountId input field + fun setManuallySelectedAddress(address : Address){ + val selectedItem = SelectedContact(address, "") + mutableSelectedContacts.value = setOf(selectedItem) + } + fun onSearchQueryChanged(query: String) { mutableSearchQuery.value = query } open fun onContactItemClicked(address: Address) { - val newSet = mutableSelectedContactAccountIDs.value.toHashSet() - if (!newSet.remove(address)) { - newSet.add(address) + val newSet = mutableSelectedContacts.value.toHashSet() + val selectedContact = contacts.value.find { it.address == address } + + if(selectedContact == null) return + + val item = SelectedContact(address = selectedContact.address, name = selectedContact.name) + if (!newSet.remove(item)) { + newSet.add(item) } - mutableSelectedContactAccountIDs.value = newSet + mutableSelectedContacts.value = newSet } fun selectAccountIDs(accountIDs: Set
) { - mutableSelectedContactAccountIDs.value += accountIDs + val toAdd = accountIDs.map { address -> SelectedContact(address) }.toSet() + mutableSelectedContacts.update { (it + toAdd).toSet() } } fun clearSelection(){ - mutableSelectedContactAccountIDs.value = emptySet() + mutableSelectedContacts.value = emptySet() } - fun toggleFooter() { - footerCollapsed.update { !it } - } - - data class CollapsibleFooterState( - val visible: Boolean = false, - val collapsed: Boolean = false, - val footerActionTitle : GetString = GetString("") - ) - @AssistedFactory interface Factory { fun create( @@ -201,3 +183,8 @@ data class ContactItem( val selected: Boolean, val showProBadge: Boolean ) + +data class SelectedContact( + val address: Address, + val name: String = "" +) 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 c510d9d4b3..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 @@ -12,18 +12,31 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +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 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 +import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.ProBadgeText +import org.thoughtcrime.securesms.ui.RadioOption import org.thoughtcrime.securesms.ui.components.Avatar +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton import org.thoughtcrime.securesms.ui.components.RadioButtonIndicator +import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -56,7 +69,7 @@ fun GroupMinimumVersionBanner(modifier: Modifier = Modifier) { } @Composable -fun MemberItem( +fun MemberItem( address: Address, title: String, avatarUIData: AvatarUIData, @@ -69,7 +82,7 @@ fun MemberItem( content: @Composable RowScope.() -> Unit = {}, ) { var itemModifier = modifier - if(onClick != null){ + if (onClick != null) { itemModifier = itemModifier.clickable(onClick = { onClick(address) }) } @@ -86,7 +99,9 @@ fun MemberItem( Avatar( size = LocalDimensions.current.iconLarge, data = avatarUIData, - badge = if (showAsAdmin) { AvatarBadge.Admin } else AvatarBadge.None + badge = if (showAsAdmin) { + AvatarBadge.Admin + } else AvatarBadge.None ) Column( @@ -125,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, @@ -133,15 +149,17 @@ fun RadioMemberItem( title = title, subtitle = subtitle, subtitleColor = subtitleColor, - onClick = if(enabled) onClick else null, + onClick = if (enabled) onClick else null, showAsAdmin = showAsAdmin, showProBadge = showProBadge, modifier = modifier - ){ - RadioButtonIndicator( - selected = selected, - enabled = enabled - ) + ) { + if (showRadioButton) { + RadioButtonIndicator( + selected = selected, + enabled = enabled + ) + } } } @@ -167,6 +185,91 @@ fun LazyListScope.multiSelectMemberList( } } +@Composable +fun InviteMembersDialog( + state: InviteMembersViewModel.InviteContactsDialogState, + modifier: Modifier = Modifier, + onInviteClicked: (Boolean) -> Unit, + onDismiss: () -> Unit, +) { + var shareHistory by remember { mutableStateOf(false) } + + AlertDialog( + modifier = modifier, + onDismissRequest = { + // hide dialog + onDismiss() + }, + title = annotatedStringResource(R.string.membersInviteTitle), + text = annotatedStringResource(state.inviteContactsBody), + content = { + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(LocalResources.current.getString(R.string.membersInviteShareMessageHistoryDays)), + selected = !shareHistory + ) + ) { + shareHistory = false + } + + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(LocalResources.current.getString(R.string.membersInviteShareNewMessagesOnly)), + selected = shareHistory, + ) + ) { + shareHistory = true + } + }, + buttons = listOf( + DialogButtonData( + text = GetString(state.inviteText), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + onDismiss() + onInviteClicked(shareHistory) + } + ), + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + onDismiss() + } + ) + ) + ) +} + +@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/InviteAccountIdScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt new file mode 100644 index 0000000000..e5b937f475 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteAccountIdScreen.kt @@ -0,0 +1,119 @@ +package org.thoughtcrime.securesms.groups.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import org.thoughtcrime.securesms.groups.InviteMembersViewModel +import org.thoughtcrime.securesms.home.startconversation.newmessage.Callbacks +import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessage +import org.thoughtcrime.securesms.home.startconversation.newmessage.NewMessageViewModel +import org.thoughtcrime.securesms.home.startconversation.newmessage.State +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog + +@Composable +internal fun InviteAccountIdScreen( + viewModel: InviteMembersViewModel, + state: State, // new message state + qrErrors: Flow = emptyFlow(), + callbacks: Callbacks = object : Callbacks {}, + onBack: () -> Unit = {}, + onHelp: () -> Unit = {}, + onDismissHelpDialog: () -> Unit, + onSendInvite: (shareHistory: Boolean) -> Unit, +) { + val uiState by viewModel.uiState.collectAsState() + + InviteAccountId( + state = state, + inviteState = uiState.inviteContactsDialog, + qrErrors = qrErrors, + callbacks = callbacks, + onBack = onBack, + onHelp = onHelp, + onDismissHelpDialog = onDismissHelpDialog, + onSendInvite = onSendInvite, + onDismissInviteDialog = { viewModel.sendCommand(InviteMembersViewModel.Commands.DismissSendInviteDialog) } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InviteAccountId( + state: State, + inviteState: InviteMembersViewModel.InviteContactsDialogState, + qrErrors: Flow = emptyFlow(), + callbacks: Callbacks = object : Callbacks {}, + onBack: () -> Unit = {}, + onHelp: () -> Unit = {}, + onDismissHelpDialog: () -> Unit, + onSendInvite: (Boolean) -> Unit, + onDismissInviteDialog: () -> Unit +) { + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + ) { paddings -> + Box( + modifier = Modifier.padding( + top = paddings.calculateTopPadding(), + bottom = paddings.calculateBottomPadding() + ) + ) { + NewMessage( + state = state, + qrErrors = qrErrors, + callbacks = callbacks, + onBack = { onBack() }, + onClose = { onBack() }, + onHelp = { onHelp() }, + isInvite = true, + ) + } + } + + if (inviteState.visible) { + InviteMembersDialog( + state = inviteState, + onInviteClicked = onSendInvite, + onDismiss = onDismissInviteDialog + ) + } + + if(!state.showUrlDialog.isNullOrEmpty()) { + OpenURLAlertDialog( + url = state.showUrlDialog, + onDismissRequest = { onDismissHelpDialog() } + ) + } +} + +@Preview +@Composable +fun PreviewInviteAccountId() { + InviteAccountId( + state = State( + newMessageIdOrOns = "", + isTextErrorColor = false, + error = null, + loading = false, + showUrlDialog = null, + validIdFromQr = "", + ), + onBack = { }, + onHelp = { }, + onSendInvite = { _ -> }, + inviteState = InviteMembersViewModel.InviteContactsDialogState(), + qrErrors = emptyFlow(), + onDismissInviteDialog = {}, + onDismissHelpDialog = {}, + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt index 51db502057..a857eb4065 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt @@ -1,5 +1,6 @@ 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.PaddingValues @@ -21,22 +22,28 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 network.loki.messenger.R import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem -import org.thoughtcrime.securesms.groups.SelectContactsViewModel -import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox +import org.thoughtcrime.securesms.groups.InviteMembersViewModel +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.CloseFooter +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.ContactItemClick +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.RemoveSearchState +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.SearchFocusChange +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.SearchQueryChange +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.ShowSendInviteDialog +import org.thoughtcrime.securesms.groups.InviteMembersViewModel.Commands.ToggleFooter import org.thoughtcrime.securesms.ui.CollapsibleFooterAction import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.SearchBar +import org.thoughtcrime.securesms.ui.SearchBarWithClose import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors @@ -47,29 +54,23 @@ import org.thoughtcrime.securesms.ui.theme.primaryBlue import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUIElement - @Composable fun InviteContactsScreen( - viewModel: SelectContactsViewModel, - onDoneClicked: () -> Unit, + viewModel: InviteMembersViewModel, + onDoneClicked: (shareHistory: Boolean) -> Unit, onBack: () -> Unit, - banner: @Composable () -> Unit = {} + banner: @Composable () -> Unit = {}, + forCommunity: Boolean = false, ) { - val footerData by viewModel.collapsibleFooterState.collectAsState() - InviteContacts( contacts = viewModel.contacts.collectAsState().value, - onContactItemClicked = viewModel::onContactItemClicked, + uiState = viewModel.uiState.collectAsState().value, searchQuery = viewModel.searchQuery.collectAsState().value, - onSearchQueryChanged = viewModel::onSearchQueryChanged, - onSearchQueryClear = { viewModel.onSearchQueryChanged("") }, onDoneClicked = onDoneClicked, onBack = onBack, banner = banner, - data = footerData, - onToggleFooter = viewModel::toggleFooter, - onCloseFooter = viewModel::clearSelection - + sendCommand = viewModel::sendCommand, + forCommunity = forCommunity ) } @@ -77,16 +78,13 @@ fun InviteContactsScreen( @Composable fun InviteContacts( contacts: List, - onContactItemClicked: (address: Address) -> Unit, + uiState: InviteMembersViewModel.UiState, searchQuery: String, - onSearchQueryChanged: (String) -> Unit, - onSearchQueryClear: () -> Unit, - onDoneClicked: () -> Unit, + onDoneClicked: (shareHistory: Boolean) -> Unit, onBack: () -> Unit, banner: @Composable () -> Unit = {}, - data: SelectContactsViewModel.CollapsibleFooterState, - onToggleFooter: () -> Unit, - onCloseFooter: () -> Unit, + sendCommand: (command: InviteMembersViewModel.Commands) -> Unit, + forCommunity: Boolean = false ) { val trayItems = listOf( @@ -94,16 +92,29 @@ fun InviteContacts( label = GetString(LocalResources.current.getString(R.string.membersInvite)), buttonLabel = GetString(LocalResources.current.getString(R.string.membersInviteTitle)), isDanger = false, - onClick = { onDoneClicked() } + onClick = { + if (forCommunity) onDoneClicked(false) // Community does not need the dialog + else sendCommand(ShowSendInviteDialog) + } ) ) + val handleBack: () -> Unit = { + when { + uiState.isSearchFocused -> sendCommand(RemoveSearchState(false)) + else -> onBack() + } + } + + // Intercept system back + BackHandler(enabled = true) { handleBack() } + Scaffold( contentWindowInsets = WindowInsets.safeDrawing, topBar = { BackAppBar( title = stringResource(id = R.string.membersInvite), - onBack = onBack, + onBack = handleBack, ) }, bottomBar = { @@ -115,13 +126,13 @@ fun InviteContacts( ) { CollapsibleFooterAction( data = CollapsibleFooterActionData( - title = data.footerActionTitle, - collapsed = data.collapsed, - visible = data.visible, + title = uiState.footer.footerActionTitle, + collapsed = uiState.footer.collapsed, + visible = uiState.footer.visible, items = trayItems ), - onCollapsedClicked = onToggleFooter, - onClosedClicked = onCloseFooter + onCollapsedClicked = { sendCommand(ToggleFooter) }, + onClosedClicked = { sendCommand(CloseFooter) } ) } } @@ -131,32 +142,37 @@ fun InviteContacts( .padding(paddings) .consumeWindowInsets(paddings), ) { - banner() - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - SearchBar( + SearchBarWithClose( query = searchQuery, - onValueChanged = onSearchQueryChanged, - onClear = onSearchQueryClear, + onValueChanged = { query -> sendCommand(SearchQueryChange(query)) }, + onClear = { sendCommand(SearchQueryChange("")) }, placeholder = stringResource(R.string.searchContacts), modifier = Modifier .padding(horizontal = LocalDimensions.current.smallSpacing) .qaTag(R.string.AccessibilityId_groupNameSearch), backgroundColor = LocalColors.current.backgroundSecondary, + isFocused = uiState.isSearchFocused, + onFocusChanged = { isFocused -> sendCommand(SearchFocusChange(isFocused)) }, + enabled = true, ) val scrollState = rememberLazyListState() Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - Box(modifier = Modifier.weight(1f)) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { if (contacts.isEmpty() && searchQuery.isEmpty()) { Text( - text = stringResource(id = R.string.contactNone), + text = stringResource(id = R.string.membersInviteNoContacts), modifier = Modifier - .padding(top = LocalDimensions.current.spacing) .align(Alignment.TopCenter), + textAlign = TextAlign.Center, style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) ) } else { @@ -166,13 +182,21 @@ fun InviteContacts( ) { multiSelectMemberList( contacts = contacts, - onContactItemClicked = onContactItemClicked, + onContactItemClicked = { address -> sendCommand(ContactItemClick(address)) }, ) } } } } } + + if (uiState.inviteContactsDialog.visible) { + InviteMembersDialog( + state = uiState.inviteContactsDialog, + onInviteClicked = onDoneClicked, + onDismiss = { } + ) + } } @Preview @@ -199,19 +223,18 @@ private fun PreviewSelectContacts() { PreviewTheme { InviteContacts( contacts = contacts, - onContactItemClicked = {}, - searchQuery = "", - onSearchQueryChanged = {}, - onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, - data = SelectContactsViewModel.CollapsibleFooterState( - collapsed = false, - visible = true, - footerActionTitle = GetString("1 Contact Selected") + banner = {}, + sendCommand = {}, + uiState = InviteMembersViewModel.UiState( + footer = InviteMembersViewModel.CollapsibleFooterState( + collapsed = false, + visible = true, + footerActionTitle = GetString("1 Contact Selected") + ) ), - onToggleFooter = { }, - onCloseFooter = { }, + searchQuery = "", ) } } @@ -224,19 +247,18 @@ private fun PreviewSelectEmptyContacts() { PreviewTheme { InviteContacts( contacts = contacts, - onContactItemClicked = {}, - searchQuery = "", - onSearchQueryChanged = {}, - onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, - data = SelectContactsViewModel.CollapsibleFooterState( - collapsed = true, - visible = false, - footerActionTitle = GetString("") + banner = {}, + sendCommand = {}, + uiState = InviteMembersViewModel.UiState( + footer = InviteMembersViewModel.CollapsibleFooterState( + collapsed = true, + visible = false, + footerActionTitle = GetString("") + ) ), - onToggleFooter = { }, - onCloseFooter = { } + searchQuery = "Test" ) } } @@ -249,19 +271,18 @@ private fun PreviewSelectEmptyContactsWithSearch() { PreviewTheme { InviteContacts( contacts = contacts, - onContactItemClicked = {}, - searchQuery = "Test", - onSearchQueryChanged = {}, - onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, - data = SelectContactsViewModel.CollapsibleFooterState( - collapsed = true, - visible = false, - footerActionTitle = GetString("") + banner = {}, + sendCommand = {}, + uiState = InviteMembersViewModel.UiState( + footer = InviteMembersViewModel.CollapsibleFooterState( + collapsed = true, + visible = false, + footerActionTitle = GetString("") + ) ), - onToggleFooter = { }, - onCloseFooter = { } + searchQuery = "" ) } } 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 23fc9d7360..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 @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.groups.compose -import android.R.attr.data import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility @@ -48,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 @@ -113,8 +111,6 @@ fun ManageMembers( ) { val searchFocused = uiState.isSearchFocused - val showingError = uiState.error - val showingOngoingAction = uiState.ongoingAction val handleBack: () -> Unit = { when { @@ -263,7 +259,7 @@ fun ManageMembers( } if (uiState.removeMembersDialog.visible) { - ShowRemoveMembersDialog( + RemoveMembersDialog( state = uiState.removeMembersDialog, sendCommand = sendCommand ) @@ -272,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 @@ -327,7 +282,7 @@ fun ShowRemoveMembersDialog( modifier = modifier, onDismissRequest = { // hide dialog - sendCommand(DismissRemoveDialog) + sendCommand(DismissRemoveMembersDialog) }, title = annotatedStringResource(R.string.remove), text = annotatedStringResource(state.removeMemberBody), @@ -358,14 +313,14 @@ fun ShowRemoveMembersDialog( color = LocalColors.current.danger, dismissOnClick = false, onClick = { - sendCommand(DismissRemoveDialog) + sendCommand(DismissRemoveMembersDialog) sendCommand(RemoveMembers(deleteMessages)) } ), DialogButtonData( text = GetString(stringResource(R.string.cancel)), onClick = { - sendCommand(DismissRemoveDialog) + sendCommand(DismissRemoveMembersDialog) } ) ) @@ -415,6 +370,7 @@ private fun EditGroupPreviewSheet() { showProBadge = true, clickable = true, statusLabel = "Invited", + isSelf = false ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), @@ -436,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"), @@ -458,7 +415,8 @@ private fun EditGroupPreviewSheet() { showAsAdmin = false, showProBadge = false, clickable = true, - statusLabel = "" + statusLabel = "", + isSelf = true ) val (_, _) = remember { mutableStateOf(null) } @@ -511,6 +469,7 @@ private fun EditGroupEditNamePreview( showProBadge = true, clickable = true, statusLabel = "Invited", + isSelf = false ) val twoMember = GroupMemberState( accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), @@ -532,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"), @@ -554,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/home/startconversation/StartConversationSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt index e19cb6de74..0a07730db8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt @@ -154,38 +154,36 @@ fun StartConversationNavHost( val viewModel = hiltViewModel() val uiState by viewModel.state.collectAsState(State()) - val helpUrl = "https://getsession.org/account-ids" - - LaunchedEffect(Unit) { - scope.launch { - viewModel.success.collect { - context.startActivity( - ConversationActivityV2.createIntent( - context, - address = it.address + LaunchedEffect(Unit) { + scope.launch { + viewModel.success.collect { + context.startActivity( + ConversationActivityV2.createIntent( + context, + address = it.address + ) ) - ) - onClose() + onClose() + } } } - } - NewMessage( - uiState, - viewModel.qrErrors, - viewModel, - onBack = { scope.launch { navigator.navigateUp() } }, - onClose = onClose, - onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) } - ) - if (uiState.showUrlDialog) { - OpenURLAlertDialog( - url = helpUrl, - onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } + NewMessage( + uiState, + viewModel.qrErrors, + viewModel, + onBack = { scope.launch { navigator.navigateUp() } }, + onClose = onClose, + onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) } ) + if (uiState.showUrlDialog != null) { + OpenURLAlertDialog( + url = uiState.showUrlDialog!!, + onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } + ) + } } - } // Create Group horizontalSlideComposable { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt index a70ebfd709..723f897f19 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/Callbacks.kt @@ -4,4 +4,6 @@ internal interface Callbacks { fun onChange(value: String) {} fun onContinue() {} fun onScanQrCode(value: String) {} + + fun onClearQrCode() {} } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt index 8981f6c99c..414b1c55a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt @@ -18,11 +18,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -53,32 +55,49 @@ private val TITLES = listOf(R.string.accountIdEnter, R.string.qrScan) internal fun NewMessage( state: State, qrErrors: Flow = emptyFlow(), - callbacks: Callbacks = object: Callbacks {}, + callbacks: Callbacks = object : Callbacks {}, onClose: () -> Unit = {}, onBack: () -> Unit = {}, onHelp: () -> Unit = {}, + isInvite: Boolean = false, ) { val pagerState = rememberPagerState { TITLES.size } - Column(modifier = Modifier.background( - LocalColors.current.backgroundSecondary, - shape = MaterialTheme.shapes.small - )) { + LaunchedEffect(state.validIdFromQr) { + if (state.validIdFromQr.isNotBlank()) { + if (isInvite) { + // switch back to the 1st tab and proceed with invite flow + pagerState.animateScrollToPage(0) + } else { + // auto-run the normal flow () + callbacks.onContinue() + } + + callbacks.onClearQrCode() + } + } + + Column( + modifier = Modifier.background( + if (isInvite) LocalColors.current.background else LocalColors.current.backgroundSecondary, + shape = MaterialTheme.shapes.small + ) + ) { // `messageNew` is now a plurals string so get the singular version - val context = LocalContext.current - val newMessageTitleTxt:String = context.resources.getQuantityString(R.plurals.messageNew, 1, 1) + val newMessageTitleTxt: String = if(isInvite) LocalResources.current.getString(R.string.membersInviteTitle) else + LocalResources.current.getQuantityString(R.plurals.messageNew, 1, 1) BackAppBar( title = newMessageTitleTxt, backgroundColor = Color.Transparent, // transparent to show the rounded shape of the container onBack = onBack, - actions = { AppBarCloseIcon(onClose = onClose) }, + actions = { if(!isInvite) AppBarCloseIcon(onClose = onClose) }, windowInsets = WindowInsets(0, 0, 0, 0), // Insets handled by the dialog ) SessionTabRow(pagerState, TITLES) HorizontalPager(pagerState) { when (TITLES[it]) { - R.string.accountIdEnter -> EnterAccountId(state, callbacks, onHelp) + R.string.accountIdEnter -> EnterAccountId(state, callbacks, onHelp, isInvite) R.string.qrScan -> QRScannerScreen(qrErrors, onScan = callbacks::onScanQrCode) } } @@ -89,9 +108,10 @@ internal fun NewMessage( private fun EnterAccountId( state: State, callbacks: Callbacks, - onHelp: () -> Unit = {} + onHelp: () -> Unit = {}, + isInvite: Boolean = false, ) { - Surface(color = LocalColors.current.backgroundSecondary) { + Surface(color = if (isInvite) LocalColors.current.background else LocalColors.current.backgroundSecondary) { Column( modifier = Modifier .fillMaxSize() @@ -117,7 +137,7 @@ private fun EnterAccountId( Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) BorderlessButtonWithIcon( - text = stringResource(R.string.messageNewDescriptionMobile), + text = stringResource(if(isInvite) R.string.inviteNewMemberGroupLink else R.string.messageNewDescriptionMobile), modifier = Modifier .qaTag(R.string.AccessibilityId_messageNewDescriptionMobile) .padding(horizontal = LocalDimensions.current.mediumSpacing) @@ -129,7 +149,9 @@ private fun EnterAccountId( ) } - Spacer(Modifier.weight(1f).heightIn(min = LocalDimensions.current.smallSpacing)) + Spacer(Modifier + .weight(1f) + .heightIn(min = LocalDimensions.current.smallSpacing)) AccentOutlineButton( modifier = Modifier @@ -143,12 +165,11 @@ private fun EnterAccountId( onClick = callbacks::onContinue ) { LoadingArcOr(state.loading) { - Text(stringResource(R.string.next)) + Text(stringResource(if(isInvite) R.string.membersInviteTitle else R.string.next)) } } } } - } @Preview diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt index d76cdcf457..9e9b3b017c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.home.startconversation.newmessage import android.app.Application -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.phrase.Phrase @@ -21,10 +20,8 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY -import org.session.libsession.utilities.upsertContact import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.PublicKeyValidation -import org.thoughtcrime.securesms.preferences.SettingsViewModel import org.thoughtcrime.securesms.ui.GetString import java.net.IDN import javax.inject.Inject @@ -33,7 +30,8 @@ import javax.inject.Inject class NewMessageViewModel @Inject constructor( private val application: Application, private val configFactory: ConfigFactoryProtocol, -): ViewModel(), Callbacks { +) : ViewModel(), Callbacks { + private val HELP_URL : String = "https://getsession.org/account-ids" private val _state = MutableStateFlow(State()) val state = _state.asStateFlow() @@ -41,7 +39,10 @@ class NewMessageViewModel @Inject constructor( private val _success = MutableSharedFlow() val success get() = _success - private val _qrErrors = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val _qrErrors = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) val qrErrors = _qrErrors.asSharedFlow() private var loadOnsJob: Job? = null @@ -52,7 +53,13 @@ class NewMessageViewModel @Inject constructor( override fun onChange(value: String) { loadOnsJob?.cancel() loadOnsJob = null - _state.update { it.copy(newMessageIdOrOns = value, isTextErrorColor = false, loading = false) } + _state.update { + it.copy( + newMessageIdOrOns = value, + isTextErrorColor = false, + loading = false + ) + } } override fun onContinue() { @@ -98,13 +105,19 @@ class NewMessageViewModel @Inject constructor( isPrefixRequired = false ) && PublicKeyValidation.hasValidPrefix(value) ) { - onPublicKey(value) + onChange(value) + _state.update { it.copy(validIdFromQr = value) } } else { _qrErrors.tryEmit(application.getString(R.string.qrNotAccountId)) + _state.update { it.copy(validIdFromQr = "") } } } } + override fun onClearQrCode() { + _state.update {it.copy(validIdFromQr = "") } + } + private fun resolveONS(ons: String) { if (loadOnsJob?.isActive == true) return @@ -114,7 +127,7 @@ class NewMessageViewModel @Inject constructor( loadOnsJob = viewModelScope.launch { try { val publicKey = withTimeout(30_000L, { - SnodeAPI.getAccountID(ons) + SnodeAPI.getAccountID(ons) }) onPublicKey(publicKey) } catch (e: Exception) { @@ -125,7 +138,12 @@ class NewMessageViewModel @Inject constructor( } private fun onError(e: Exception) { - _state.update { it.copy(loading = false, isTextErrorColor = true, error = GetString(e) { it.toMessage() }) } + _state.update { + it.copy( + loading = false, + isTextErrorColor = true, + error = GetString(e) { it.toMessage() }) + } } private fun onPublicKey(publicKey: String) { @@ -141,7 +159,13 @@ class NewMessageViewModel @Inject constructor( if (PublicKeyValidation.hasValidPrefix(publicKey)) { onPublicKey(publicKey) } else { - _state.update { it.copy(isTextErrorColor = true, error = GetString(R.string.accountIdErrorInvalid), loading = false) } + _state.update { + it.copy( + isTextErrorColor = true, + error = GetString(R.string.accountIdErrorInvalid), + loading = false + ) + } } } @@ -155,13 +179,13 @@ class NewMessageViewModel @Inject constructor( fun onCommand(commands: Commands) { when (commands) { is Commands.ShowUrlDialog -> { - _state.update { it.copy(showUrlDialog = true) } + _state.update { it.copy(showUrlDialog = HELP_URL) } } is Commands.DismissUrlDialog -> { _state.update { it.copy( - showUrlDialog = false + showUrlDialog = null ) } } @@ -179,12 +203,11 @@ data class State( val isTextErrorColor: Boolean = false, val error: GetString? = null, val loading: Boolean = false, - val showUrlDialog : Boolean = false + val showUrlDialog: String? = null, + val validIdFromQr: String = "", ) { val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank() } - - data class Success(val address: Address.Standard) \ 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