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 a4c2bc8f49..d2a2c5239f 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 @@ -23,6 +23,7 @@ class GroupInviteException( val isPromotion: Boolean, val inviteeAccountIds: List, val groupName: String, + val isReinvite: Boolean, underlying: Throwable ) : RuntimeException(underlying) { init { @@ -41,19 +42,26 @@ class GroupInviteException( val third = inviteeAccountIds.getOrNull(2)?.let(getInviteeName) if (second != null && third != null) { - return Phrase.from(context, if (isPromotion) R.string.adminPromotionFailedDescriptionMultiple else R.string.groupInviteFailedMultiple) + val errorString = + if (isPromotion) R.string.adminPromotionFailedDescriptionMultiple else + if (isReinvite) R.string.failedResendInviteMultiple else R.string.groupInviteFailedMultiple + return Phrase.from(context, errorString) .put(NAME_KEY, first) .put(COUNT_KEY, inviteeAccountIds.size - 1) .put(GROUP_NAME_KEY, groupName) .format() } else if (second != null) { - return Phrase.from(context, if (isPromotion) R.string.adminPromotionFailedDescriptionTwo else R.string.groupInviteFailedTwo) + val errorString = if (isPromotion) R.string.adminPromotionFailedDescriptionTwo else + if (isReinvite) R.string.failedResendInviteTwo else R.string.groupInviteFailedTwo + return Phrase.from(context, errorString) .put(NAME_KEY, first) .put(OTHER_NAME_KEY, second) .put(GROUP_NAME_KEY, groupName) .format() } else { - return Phrase.from(context, if (isPromotion) R.string.adminPromotionFailedDescription else R.string.groupInviteFailedUser) + val errorString = if (isPromotion) R.string.adminPromotionFailedDescription else + if (isReinvite) R.string.failedResendInvite else R.string.groupInviteFailedUser + return Phrase.from(context, errorString) .put(NAME_KEY, first) .put(GROUP_NAME_KEY, groupName) .format() 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 b8ea9990ec..097341f1e9 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 @@ -6,6 +6,7 @@ import org.session.libsession.messaging.messages.control.GroupUpdated import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateDeleteMemberContentMessage import org.session.libsignal.utilities.AccountId +import org.thoughtcrime.securesms.groups.MemberInvite /** * Business logic handling group v2 operations like inviting members, @@ -25,6 +26,11 @@ interface GroupManagerV2 { isReinvite: Boolean, // Whether this comes from a re-invite ) + suspend fun reinviteMembers( + group: AccountId, + invites: List + ) + suspend fun removeMembers( groupAccountId: AccountId, removedMembers: List, diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt index 1a4c5cc9f9..9109309e93 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt @@ -22,7 +22,7 @@ import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateM import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log -class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array) : Job { +class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array, val isReinvite : Boolean) : Job { companion object { const val KEY = "InviteContactJob" @@ -122,6 +122,7 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< inviteeAccountIds = failures.map { it.first }, groupName = groupName.orEmpty(), underlying = firstError, + isReinvite = isReinvite ).format(MessagingModuleConfiguration.shared.context, MessagingModuleConfiguration.shared.recipientRepository).let { withContext(Dispatchers.Main) { 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 5a540d1d0c..4dcc5837e4 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 @@ -26,12 +26,11 @@ import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.Disappear import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination.* import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsScreen import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsViewModel -import org.thoughtcrime.securesms.groups.EditGroupViewModel +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel import org.thoughtcrime.securesms.groups.GroupMembersViewModel import org.thoughtcrime.securesms.groups.SelectContactsViewModel -import org.thoughtcrime.securesms.groups.compose.EditGroupScreen +import org.thoughtcrime.securesms.groups.compose.ManageGroupMembersScreen import org.thoughtcrime.securesms.groups.compose.GroupMembersScreen -import org.thoughtcrime.securesms.groups.compose.GroupMinimumVersionBanner import org.thoughtcrime.securesms.groups.compose.InviteContactsScreen import org.thoughtcrime.securesms.media.MediaOverviewScreen import org.thoughtcrime.securesms.media.MediaOverviewViewModel @@ -182,20 +181,12 @@ fun ConversationSettingsNavHost( val data: RouteManageMembers = backStackEntry.toRoute() val viewModel = - hiltViewModel { factory -> - factory.create(data.groupAddress) + hiltViewModel { factory -> + factory.create(data.groupAddress, navigator) } - EditGroupScreen( + ManageGroupMembersScreen( viewModel = viewModel, - navigateToInviteContact = { - navController.navigate( - RouteInviteToGroup( - groupAddress = data.groupAddress, - excludingAccountIDs = viewModel.excludingAccountIDsFromContactSelection.toList() - ) - ) - }, onBack = dropUnlessResumed { handleBack() }, @@ -219,22 +210,20 @@ fun ConversationSettingsNavHost( RouteManageMembers(data.groupAddress) ) } - val editGroupViewModel: EditGroupViewModel = hiltViewModel(parentEntry) + val manageGroupMembersViewModel: ManageGroupMembersViewModel = hiltViewModel(parentEntry) InviteContactsScreen( viewModel = viewModel, onDoneClicked = dropUnlessResumed { //send invites from the manage group screen - editGroupViewModel.onContactSelected(viewModel.currentSelected) + manageGroupMembersViewModel.onContactSelected(viewModel.currentSelected) handleBack() }, onBack = dropUnlessResumed { handleBack() }, - banner = { - GroupMinimumVersionBanner() - } + banner = {} ) } 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 632877c0b0..f71efb86ee 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 @@ -282,6 +282,19 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) } + private val optionManageAdmins: OptionsItem by lazy{ + OptionsItem( + name = context.getString(R.string.manageAdmins), + icon = R.drawable.ic_add_admin_custom, + qaTag = R.string.qa_conversation_settings_manage_members, + onClick = { + (address as? Address.Group)?.let { + navigateTo(ConversationSettingsDestination.RouteManageMembers(it)) + } + } + ) + } + private val optionLeaveGroup: OptionsItem by lazy{ OptionsItem( name = context.getString(R.string.groupLeave), @@ -569,6 +582,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( dangerOptions.addAll( listOf( optionClearMessages, + optionLeaveGroup, optionDeleteGroup ) ) @@ -577,6 +591,7 @@ class ConversationSettingsViewModel @AssistedInject constructor( adminOptions.addAll( listOf( optionManageMembers, + optionManageAdmins, optionDisappearingMessage(disappearingSubtitle) ) ) 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 28671f9f19..9aa032af14 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/BaseGroupMembersViewModel.kt @@ -89,6 +89,16 @@ abstract class BaseGroupMembersViewModel( ::filterContacts ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + // Output: List of only NON-ADMINS + val nonAdminMembers: StateFlow> = members + .map { list -> list.filter { !it.showAsAdmin } } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val hasNonAdminMembers: StateFlow = + groupInfo + .map { pair -> pair?.second.orEmpty().any { !it.showAsAdmin } } + .stateIn(viewModelScope, SharingStarted.Lazily, false) + fun onSearchQueryChanged(query: String) { mutableSearchQuery.value = query } @@ -140,7 +150,7 @@ abstract class BaseGroupMembersViewModel( showProBadge = proStatus.shouldShowProBadge(), avatarUIData = avatarUtils.getUIDataFromAccountId(memberAccountId.hexString), clickable = !isMyself, - statusLabel = getMemberLabel(status, context, amIAdmin), + statusLabel = getMemberLabel(status, context, amIAdmin) ) } @@ -172,16 +182,35 @@ abstract class BaseGroupMembersViewModel( } } - // Refer to notion doc for the sorting logic + // Refer to manage members/admin PRD for the sorting logic private fun sortMembers(members: List, currentUserId: AccountId) = members.sortedWith( - compareBy{ it.accountId != currentUserId } // Current user comes first - .thenBy { !it.showAsAdmin } // Admins come first - .thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) // Sort by name (case insensitive) - .thenBy { it.accountId } // Last resort: sort by account ID + compareBy { stateOrder(it.status) } + .thenBy { it.accountId != currentUserId } + .thenComparing(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + .thenBy { it.accountId } ) } +private fun stateOrder(status: GroupMember.Status?): Int = when (status) { + // 1. Invite failed + GroupMember.Status.INVITE_FAILED -> 0 + // 2. Invite not sent + GroupMember.Status.INVITE_NOT_SENT -> 1 + // 3. Sending invite + GroupMember.Status.INVITE_SENDING -> 2 + // 4. Invite sent + GroupMember.Status.INVITE_SENT -> 3 + // 5. Invite status unknown + GroupMember.Status.INVITE_UNKNOWN -> 4 + // 6. Pending removal + GroupMember.Status.REMOVED, + GroupMember.Status.REMOVED_UNKNOWN, + GroupMember.Status.REMOVED_INCLUDING_MESSAGES -> 5 + // 7. Member (everything else) + else -> 6 +} + data class GroupMemberState( val accountId: AccountId, val avatarUIData: AvatarUIData, @@ -195,7 +224,7 @@ data class GroupMemberState( val canRemove: Boolean, val canPromote: Boolean, val clickable: Boolean, - val statusLabel: String, + val statusLabel: String ) { val canEdit: Boolean get() = canRemove || canPromote || canResendInvite || canResendPromotion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt deleted file mode 100644 index bacdf3ae37..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/EditGroupViewModel.kt +++ /dev/null @@ -1,190 +0,0 @@ -package org.thoughtcrime.securesms.groups - -import android.content.Context -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.GlobalScope -import kotlinx.coroutines.async -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.launch -import network.loki.messenger.R -import network.loki.messenger.libsession_util.getOrNull -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.session.libsignal.utilities.AccountId -import org.thoughtcrime.securesms.database.RecipientRepository -import org.thoughtcrime.securesms.util.AvatarUtils - - -@HiltViewModel(assistedFactory = EditGroupViewModel.Factory::class) -class EditGroupViewModel @AssistedInject constructor( - @Assisted private val groupAddress: Address.Group, - @param:ApplicationContext private val context: Context, - storage: StorageProtocol, - private val configFactory: ConfigFactoryProtocol, - private val groupManager: GroupManagerV2, - private val recipientRepository: RecipientRepository, - avatarUtils: AvatarUtils, -) : 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 } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) - - // Output: Intermediate states - private val mutableInProgress = MutableStateFlow(false) - val inProgress: StateFlow get() = mutableInProgress - - // show action bottom sheet - private val _clickedMember: MutableStateFlow = MutableStateFlow(null) - val clickedMember: StateFlow get() = _clickedMember - - // Output: errors - private val mutableError = MutableStateFlow(null) - val error: StateFlow get() = mutableError - - // Output: - val excludingAccountIDsFromContactSelection: Set - get() = groupInfo.value?.second?.mapTo(hashSetOf()) { it.accountId.hexString }.orEmpty() - - fun onContactSelected(contacts: Set
) { - performGroupOperation( - showLoading = false, - errorMessage = { err -> - if (err is GroupInviteException) { - err.format(context, recipientRepository).toString() - } else { - null - } - } - ) { - groupManager.inviteMembers( - groupId, - contacts.map { AccountId(it.toString()) }.toList(), - shareHistory = false, - isReinvite = false, - ) - } - } - - fun onResendInviteClicked(contactSessionId: AccountId) { - performGroupOperation( - showLoading = false, - errorMessage = { err -> - if (err is GroupInviteException) { - err.format(context, recipientRepository).toString() - } else { - null - } - } - ) { - val historyShared = configFactory.withGroupConfigs(groupId) { - it.groupMembers.getOrNull(contactSessionId.hexString) - }?.supplement == true - - groupManager.inviteMembers( - groupId, - listOf(contactSessionId), - shareHistory = historyShared, - isReinvite = true, - ) - } - } - - fun onPromoteContact(memberSessionId: AccountId) { - performGroupOperation(showLoading = false) { - groupManager.promoteMember(groupId, listOf(memberSessionId), isRepromote = false) - } - } - - fun onRemoveContact(contactSessionId: AccountId, removeMessages: Boolean) { - performGroupOperation(showLoading = false) { - groupManager.removeMembers( - groupAccountId = groupId, - removedMembers = listOf(contactSessionId), - removeMessages = removeMessages - ) - } - } - - fun onResendPromotionClicked(memberSessionId: AccountId) { - performGroupOperation(showLoading = false) { - groupManager.promoteMember(groupId, listOf(memberSessionId), isRepromote = true) - } - } - - fun onDismissError() { - mutableError.value = 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) { - mutableInProgress.value = 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) { - mutableError.value = errorMessage?.invoke(e) - ?: context.getString(R.string.errorUnknown) - } finally { - if (showLoading) { - mutableInProgress.value = false - } - } - } - } - - fun onMemberClicked(groupMember: GroupMemberState){ - // if the member is clickable (ie, not 'you') but is an admin with no possible actions, - // show a toast mentioning they can't be removed - if(!groupMember.canEdit && groupMember.showAsAdmin){ - mutableError.value = context.getString(R.string.adminCannotBeRemoved) - } else { // otherwise pass in the clicked member to display the action sheet - _clickedMember.value = groupMember - } - } - - fun hideActionBottomSheet(){ - _clickedMember.value = null - } - - @AssistedFactory - interface Factory { - fun create(groupAddress: Address.Group): EditGroupViewModel - } -} 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 c5e3acf8dc..82623a4f4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -4,7 +4,6 @@ import android.content.Context import com.google.protobuf.ByteString import com.squareup.phrase.Phrase import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async @@ -70,9 +69,12 @@ import org.thoughtcrime.securesms.util.SessionMetaProtocol import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +import kotlin.collections.map private const val TAG = "GroupManagerV2Impl" +data class MemberInvite(val id: AccountId, val shareHistory: Boolean) + @Singleton class GroupManagerV2Impl @Inject constructor( private val storage: StorageProtocol, @@ -203,7 +205,8 @@ class GroupManagerV2Impl @Inject constructor( JobQueue.shared.add( InviteContactsJob( groupSessionId = groupId.hexString, - memberSessionIds = members.map { it.hexString }.toTypedArray() + memberSessionIds = members.map { it.hexString }.toTypedArray(), + false ) ) @@ -218,45 +221,64 @@ class GroupManagerV2Impl @Inject constructor( } } - override suspend fun inviteMembers( group: AccountId, newMembers: List, shareHistory: Boolean, isReinvite: Boolean - ): Unit = scope.launchAndWait(group, "Invite members") { + ): Unit = inviteMembersInternal( + group = group, + memberInvites = newMembers.map { MemberInvite(it, shareHistory) }, + isReinvite = isReinvite + ) + + override suspend fun reinviteMembers( + group: AccountId, + invites: List + ): Unit = inviteMembersInternal( + group = group, + memberInvites = invites, + isReinvite = true + ) + + private suspend fun inviteMembersInternal( + group: AccountId, + memberInvites: List, + isReinvite: Boolean + ): Unit = scope.launchAndWait(group, if (isReinvite) "Reinvite members" else "Invite members") { val adminKey = requireAdminAccess(group) val groupAuth = OwnedSwarmAuth.ofClosedGroup(group, adminKey) val batchRequests = mutableListOf() - // Construct the new members in our config val subAccountTokens = configFactory.withMutableGroupConfigs(group) { configs -> - // Construct the new members in the config - for (newMember in newMembers) { - val toSet = configs.groupMembers.get(newMember.hexString) + val shareHistoryHexes = mutableListOf() + + for ((id, shareHistory) in memberInvites) { + val hex = id.hexString + + val toSet = configs.groupMembers.get(hex) ?.also { existing -> val status = configs.groupMembers.status(existing) if (status == GroupMember.Status.INVITE_FAILED || status == GroupMember.Status.INVITE_SENT) { existing.setSupplement(shareHistory) } } - ?: configs.groupMembers.getOrConstruct(newMember.hexString).also { member -> - val contact = configFactory.withUserConfigs { configs -> - configs.contacts.get(newMember.hexString) - } - + ?: configs.groupMembers.getOrConstruct(hex).also { member -> + val contact = configFactory.withUserConfigs { it.contacts.get(hex) } member.setName(contact?.name.orEmpty()) member.setProfilePic(contact?.profilePicture ?: UserPic.DEFAULT) member.setSupplement(shareHistory) } + if (shareHistory) shareHistoryHexes += hex + toSet.setInvited() configs.groupMembers.set(toSet) } - if (shareHistory) { - val memberKey = configs.groupKeys.supplementFor(newMembers.map { it.hexString }) + if (shareHistoryHexes.isNotEmpty()) { + val memberKey = configs.groupKeys.supplementFor(shareHistoryHexes) batchRequests.add( SnodeAPI.buildAuthenticatedStoreBatchInfo( namespace = Namespace.GROUP_KEYS(), @@ -272,7 +294,7 @@ class GroupManagerV2Impl @Inject constructor( } configs.rekey() - newMembers.map { configs.groupKeys.getSubAccountToken(it.hexString) } + memberInvites.map { configs.groupKeys.getSubAccountToken(it.id.hexString) } } // Call un-revocate API on new members, in case they have been removed before @@ -281,7 +303,6 @@ class GroupManagerV2Impl @Inject constructor( subAccountTokens = subAccountTokens ) - // Call the API try { val swarmNode = SnodeAPI.getSingleTargetSnode(group.hexString).await() val response = SnodeAPI.getBatchResponse(swarmNode, group.hexString, batchRequests) @@ -294,13 +315,12 @@ class GroupManagerV2Impl @Inject constructor( } catch (e: Exception) { // Update every member's status to "invite failed" and return group name val groupName = configFactory.withMutableGroupConfigs(group) { configs -> - for (newMember in newMembers) { - configs.groupMembers.get(newMember.hexString)?.apply { + for ((id, _) in memberInvites) { + configs.groupMembers.get(id.hexString)?.apply { setInviteFailed() configs.groupMembers.set(this) } } - configs.groupInfo.getName().orEmpty() } @@ -308,14 +328,15 @@ class GroupManagerV2Impl @Inject constructor( throw GroupInviteException( isPromotion = false, - inviteeAccountIds = newMembers.map { it.hexString }, + inviteeAccountIds = memberInvites.map { it.id.hexString }, groupName = groupName, - underlying = e + underlying = e, + isReinvite = isReinvite ) } finally { // Send a group update message to the group telling members someone has been invited if (!isReinvite) { - sendGroupUpdateForAddingMembers(group, adminKey, newMembers) + sendGroupUpdateForAddingMembers(group, adminKey, memberInvites.map { it.id }) } } @@ -323,11 +344,12 @@ class GroupManagerV2Impl @Inject constructor( JobQueue.shared.add( InviteContactsJob( group.hexString, - newMembers.map { it.hexString }.toTypedArray() + memberInvites.map { it.id.hexString }.toTypedArray(), isReinvite ) ) } + /** * Send a group update message to the group telling members someone has been invited. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt new file mode 100644 index 0000000000..e40099c29d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ManageGroupMembersViewModel.kt @@ -0,0 +1,479 @@ +package org.thoughtcrime.securesms.groups + +import android.content.Context +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +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.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 network.loki.messenger.R +import network.loki.messenger.libsession_util.getOrNull +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.session.libsession.utilities.StringSubstitutionConstants.COUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.OTHER_NAME_KEY +import org.session.libsignal.utilities.AccountId +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 + + +@HiltViewModel(assistedFactory = ManageGroupMembersViewModel.Factory::class) +class ManageGroupMembersViewModel @AssistedInject constructor( + @Assisted private val groupAddress: Address.Group, + @Assisted private val navigator: UINavigator, + @param:ApplicationContext private val context: Context, + storage: StorageProtocol, + private val configFactory: ConfigFactoryProtocol, + private val groupManager: GroupManagerV2, + private val recipientRepository: RecipientRepository, + avatarUtils: AvatarUtils, +) : 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 } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) + + // Output: + val excludingAccountIDsFromContactSelection: Set + get() = groupInfo.value?.second?.mapTo(hashSetOf()) { it.accountId.hexString }.orEmpty() + + private val _mutableSelectedMembers = MutableStateFlow(emptySet()) + val selectedMembers: StateFlow> = _mutableSelectedMembers + + private val footerCollapsed = MutableStateFlow(false) + + private val optionsList: List by lazy { + listOf( + OptionsItem( + name = context.getString(R.string.membersInvite), + icon = R.drawable.ic_user_round_plus, + onClick = ::navigateInviteContacts + ), + OptionsItem( + name = context.getString(R.string.accountIdOrOnsInvite), + icon = R.drawable.ic_user_round_search, + onClick = { + // TODO: Add navigation + } + ) + ) + } + + private val _uiState = MutableStateFlow(UiState(options = optionsList)) + val uiState: StateFlow = _uiState + + private val showRemoveMembersDialog = MutableStateFlow(false) + + init { + viewModelScope.launch { + combine(showRemoveMembersDialog, selectedMembers, groupName) { showRemove, selected, group -> + buildRemoveMembersDialogState(showRemove, selected, group) + }.collect { state -> + _uiState.update { it.copy(removeMembersDialog = state) } + } + } + + viewModelScope.launch { + combine(selectedMembers, footerCollapsed) { selected, isCollapsed -> + buildFooterState(selected, isCollapsed) + }.collect { footer -> + _uiState.update { it.copy(footer = footer) } + } + } + } + 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) } + } + + private fun navigateInviteContacts() { + viewModelScope.launch { + navigator.navigate( + ConversationSettingsDestination.RouteInviteToGroup( + groupAddress, + excludingAccountIDsFromContactSelection.toList() + ) + ) + } + } + + fun onContactSelected(contacts: Set
) { + performGroupOperation( + showLoading = false, + errorMessage = { err -> + if (err is GroupInviteException) { + err.format(context, recipientRepository).toString() + } else { + null + } + } + ) { + groupManager.inviteMembers( + groupId, + contacts.map { AccountId(it.toString()) }.toList(), + shareHistory = false, + isReinvite = false, + ) + } + } + + fun onResendInviteClicked() { + if (selectedMembers.value.isEmpty()) return + performGroupOperation( + showLoading = false, + errorMessage = { err -> + if (err is GroupInviteException) { + err.format(context, recipientRepository).toString() + } else { + null + } + } + ) { + // Look up current member configs once + val invites: List = configFactory.withGroupConfigs(groupId) { cfg -> + selectedMembers.value.map { member -> + val shareHistory = + cfg.groupMembers.getOrNull(member.accountId.hexString)?.supplement == true + MemberInvite(id = member.accountId, shareHistory = shareHistory) + } + } + + removeSearchState(true) + + _uiState.update { it -> + it.copy(error = context.resources.getQuantityString( + R.plurals.resendingInvite, + invites.size, + invites.size + )) + } + + // Reinvite with per-member shareHistory + groupManager.reinviteMembers( + group = groupId, + invites = invites + ) + } + } + + fun removeSearchState(clearSelection : Boolean){ + onSearchFocusChanged(false) + onSearchQueryChanged("") + + if(clearSelection){ + clearSelection() + } + } + + 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 + )) + } + + performGroupOperation(showLoading = false) { + val accountIdList = selectedMembers.value.map { it.accountId } + + removeSearchState(true) + + groupManager.removeMembers( + groupAccountId = groupId, + removedMembers = accountIdList, + removeMessages = removeMessages + ) + } + } + + 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() + } + + fun toggleFooter() { + footerCollapsed.update { !it } + } + + fun onDismissResend() { + _uiState.update { it.copy(ongoingAction = null) } + } + + private fun toggleRemoveDialog(visible : Boolean){ + showRemoveMembersDialog.value = visible + } + + fun onCommand(command: Commands) { + when (command) { + is Commands.ShowRemoveDialog -> toggleRemoveDialog(true) + + is Commands.DismissRemoveDialog -> toggleRemoveDialog(false) + + is Commands.RemoveMembers -> onRemoveContact(command.removeMessages) + + is Commands.ClearSelection, + + is Commands.CloseFooter -> clearSelection() + + is Commands.ToggleFooter -> toggleFooter() + + is Commands.DismissError -> onDismissError() + + is Commands.DismissResend -> onDismissResend() + + is Commands.MemberClick -> onMemberItemClicked(command.member) + + is Commands.RemoveSearchState -> removeSearchState(command.clearSelection) + + is Commands.SearchFocusChange -> onSearchFocusChanged(command.focus) + + is Commands.SearchQueryChange -> onSearchQueryChanged(command.query) + } + } + + private fun buildRemoveMembersDialogState( + visible: Boolean, + selected: Set, + group: String + ): RemoveMembersDialogState { + val count = selected.size + val firstMember = selected.firstOrNull() + + val body: CharSequence = when (count) { + 1 -> Phrase.from(context, R.string.groupRemoveDescription) + .put(NAME_KEY, firstMember?.name) + .put(GROUP_NAME_KEY, group) + .format() + + 2 -> { + val secondMember = selected.elementAtOrNull(1)?.name + Phrase.from(context, R.string.groupRemoveDescriptionTwo) + .put(NAME_KEY, firstMember?.name) + .put(OTHER_NAME_KEY, secondMember) + .put(GROUP_NAME_KEY, group) + .format() + } + + 0 -> "" + else -> Phrase.from(context, R.string.groupRemoveDescriptionMultiple) + .put(NAME_KEY, firstMember?.name) + .put(COUNT_KEY, count - 1) + .put(GROUP_NAME_KEY, group) + .format() + } + + val removeMemberOnly = + context.resources.getQuantityString(R.plurals.removeMember, count, count) + val removeMessages = + context.resources.getQuantityString(R.plurals.removeMemberMessages, count, count) + + return RemoveMembersDialogState( + visible = visible, + removeMemberBody = body, + removeMemberText = removeMemberOnly, + removeMessagesText = removeMessages + ) + } + + 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 trayItems = listOf( + CollapsibleFooterItemData( + label = GetString( + context.resources.getQuantityString(R.plurals.resendInvite, count, count) + ), + buttonLabel = GetString(context.getString(R.string.resend)), + isDanger = false, + onClick = { onResendInviteClicked() } + ), + CollapsibleFooterItemData( + label = GetString( + context.resources.getQuantityString(R.plurals.removeMember, count, count) + ), + buttonLabel = GetString(context.getString(R.string.remove)), + isDanger = true, + onClick = { onCommand(Commands.ShowRemoveDialog) } + ) + ) + + return CollapsibleFooterState( + visible = visible, + collapsed = if (!visible) false else isCollapsed, + footerActionTitle = title, + footerActionItems = trayItems + ) + } + + data class UiState( + val options : List = emptyList(), + + val inProgress: Boolean = false, + val error: String? = null, + val ongoingAction: String? = null, + + // search UI state: + val searchQuery: String = "", + val isSearchFocused: Boolean = false, + + // Remove member dialog + val removeMembersDialog: RemoveMembersDialogState = RemoveMembersDialogState(), + + //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 RemoveMembersDialogState( + val visible : Boolean = false, + val removeMemberBody : CharSequence = "", + val removeMemberText : String = "", + val removeMessagesText : String = "" + ) + + data class OptionsItem( + val name: String, + @DrawableRes val icon: Int, + @StringRes val qaTag: Int? = null, + val onClick: () -> Unit + ) + + sealed interface Commands { + data object ShowRemoveDialog : Commands + data object DismissRemoveDialog : Commands + + data object DismissError : Commands + + data object DismissResend : 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 RemoveMembers(val removeMessages: Boolean) : Commands + + data class MemberClick(val member: GroupMemberState) : Commands + } + + @AssistedFactory + interface Factory { + fun create( + groupAddress: Address.Group, + navigator: UINavigator + ): ManageGroupMembersViewModel + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt deleted file mode 100644 index 2a5b7071b4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/EditGroupScreen.kt +++ /dev/null @@ -1,560 +0,0 @@ -package org.thoughtcrime.securesms.groups.compose - -import android.widget.Toast -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -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.heightIn -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -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.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment.Companion.CenterHorizontally -import androidx.compose.ui.Alignment.Companion.CenterVertically -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import com.squareup.phrase.Phrase -import network.loki.messenger.BuildConfig -import network.loki.messenger.R -import network.loki.messenger.libsession_util.util.GroupMember -import org.session.libsession.utilities.Address -import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY -import org.session.libsignal.utilities.AccountId -import org.thoughtcrime.securesms.groups.EditGroupViewModel -import org.thoughtcrime.securesms.groups.GroupMemberState -import org.thoughtcrime.securesms.ui.AlertDialog -import org.thoughtcrime.securesms.ui.DialogButtonData -import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.LoadingDialog -import org.thoughtcrime.securesms.ui.components.ActionSheet -import org.thoughtcrime.securesms.ui.components.ActionSheetItemData -import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.AccentOutlineButton -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 -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 -import org.thoughtcrime.securesms.ui.theme.primaryBlue -import org.thoughtcrime.securesms.util.AvatarUIData -import org.thoughtcrime.securesms.util.AvatarUIElement - -@Composable -fun EditGroupScreen( - viewModel: EditGroupViewModel, - navigateToInviteContact: (Set) -> Unit, - onBack: () -> Unit, -) { - EditGroup( - onBack = onBack, - onAddMemberClick = { navigateToInviteContact(viewModel.excludingAccountIDsFromContactSelection) }, - onResendInviteClick = viewModel::onResendInviteClicked, - onPromoteClick = viewModel::onPromoteContact, - onRemoveClick = viewModel::onRemoveContact, - members = viewModel.members.collectAsState().value, - groupName = viewModel.groupName.collectAsState().value, - showAddMembers = viewModel.showAddMembers.collectAsState().value, - onResendPromotionClick = viewModel::onResendPromotionClicked, - showingError = viewModel.error.collectAsState().value, - onErrorDismissed = viewModel::onDismissError, - onMemberClicked = viewModel::onMemberClicked, - hideActionSheet = viewModel::hideActionBottomSheet, - clickedMember = viewModel.clickedMember.collectAsState().value, - showLoading = viewModel.inProgress.collectAsState().value, - ) -} - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EditGroup( - onBack: () -> Unit, - onAddMemberClick: () -> Unit, - onResendInviteClick: (accountId: AccountId) -> Unit, - onResendPromotionClick: (accountId: AccountId) -> Unit, - onPromoteClick: (accountId: AccountId) -> Unit, - onRemoveClick: (accountId: AccountId, removeMessages: Boolean) -> Unit, - onMemberClicked: (GroupMemberState) -> Unit, - hideActionSheet: () -> Unit, - clickedMember: GroupMemberState?, - groupName: String, - members: List, - showAddMembers: Boolean, - showingError: String?, - showLoading: Boolean, - onErrorDismissed: () -> Unit, -) { - val (showingConfirmRemovingMember, setShowingConfirmRemovingMember) = remember { - mutableStateOf(null) - } - - val maxNameWidth = 240.dp - - Scaffold( - topBar = { - BackAppBar( - title = stringResource(id = R.string.manageMembers), - onBack = onBack, - ) - }, - contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), - ) { paddingValues -> - Column(modifier = Modifier.padding(paddingValues).consumeWindowInsets(paddingValues)) { - GroupMinimumVersionBanner() - - // Group name title - Text( - text = groupName, - style = LocalType.current.h4, - textAlign = TextAlign.Center, - modifier = Modifier - .align(CenterHorizontally) - .widthIn(max = maxNameWidth) - .padding(vertical = LocalDimensions.current.smallSpacing), - ) - - // Header & Add member button - Row( - modifier = Modifier.padding( - horizontal = LocalDimensions.current.smallSpacing, - vertical = LocalDimensions.current.xxsSpacing - ), - verticalAlignment = CenterVertically - ) { - Text( - stringResource(R.string.groupMembers), - modifier = Modifier.weight(1f), - style = LocalType.current.large, - color = LocalColors.current.text - ) - - if (showAddMembers) { - AccentOutlineButton( - stringResource(R.string.membersInvite), - onClick = onAddMemberClick, - modifier = Modifier.qaTag(R.string.AccessibilityId_membersInvite) - ) - } - } - - - // List of members - LazyColumn(modifier = Modifier.weight(1f).imePadding()) { - items(members) { member -> - // Each member's view - EditMemberItem( - modifier = Modifier.fillMaxWidth(), - member = member, - onClick = { onMemberClicked(member) } - ) - } - - item { - Spacer( - modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars) - ) - } - } - } - } - - if (clickedMember != null) { - MemberActionSheet( - onDismissRequest = hideActionSheet, - onRemove = { - setShowingConfirmRemovingMember(clickedMember) - hideActionSheet() - }, - onPromote = { - onPromoteClick(clickedMember.accountId) - hideActionSheet() - }, - onResendInvite = { - onResendInviteClick(clickedMember.accountId) - hideActionSheet() - }, - onResendPromotion = { - onResendPromotionClick(clickedMember.accountId) - hideActionSheet() - }, - member = clickedMember, - ) - } - - if (showingConfirmRemovingMember != null) { - ConfirmRemovingMemberDialog( - onDismissRequest = { - setShowingConfirmRemovingMember(null) - }, - onConfirmed = onRemoveClick, - member = showingConfirmRemovingMember, - groupName = groupName, - ) - } - - if (showLoading) { - LoadingDialog() - } - - val context = LocalContext.current - - LaunchedEffect(showingError) { - if (showingError != null) { - Toast.makeText(context, showingError, Toast.LENGTH_SHORT).show() - onErrorDismissed() - } - } -} - -@Composable -private fun GroupNameContainer(content: @Composable RowScope.() -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 72.dp), - horizontalArrangement = Arrangement.spacedBy( - LocalDimensions.current.xxxsSpacing, - CenterHorizontally - ), - verticalAlignment = CenterVertically, - content = content - ) -} - -@Composable -private fun ConfirmRemovingMemberDialog( - onConfirmed: (accountId: AccountId, removeMessages: Boolean) -> Unit, - onDismissRequest: () -> Unit, - member: GroupMemberState, - groupName: String, -) { - val context = LocalContext.current - val buttons = buildList { - this += DialogButtonData( - text = GetString(R.string.remove), - color = LocalColors.current.danger, - onClick = { onConfirmed(member.accountId, false) } - ) - - this += DialogButtonData( - text = GetString(R.string.cancel), - onClick = onDismissRequest, - ) - } - - AlertDialog( - onDismissRequest = onDismissRequest, - text = annotatedStringResource(Phrase.from(context, R.string.groupRemoveDescription) - .put(NAME_KEY, member.name) - .put(GROUP_NAME_KEY, groupName) - .format()), - title = AnnotatedString(stringResource(R.string.remove)), - buttons = buttons - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun MemberActionSheet( - member: GroupMemberState, - onRemove: () -> Unit, - onPromote: () -> Unit, - onResendInvite: () -> Unit, - onResendPromotion: () -> Unit, - onDismissRequest: () -> Unit, -) { - val context = LocalContext.current - - val options = remember(member) { - buildList { - if (member.canRemove) { - this += ActionSheetItemData( - title = context.resources.getQuantityString(R.plurals.groupRemoveUserOnly, 1), - iconRes = R.drawable.ic_trash_2, - onClick = onRemove, - qaTag = R.string.AccessibilityId_removeContact - ) - } - - if (BuildConfig.BUILD_TYPE != "release" && member.canPromote) { - this += ActionSheetItemData( - title = context.getString(R.string.adminPromoteToAdmin), - iconRes = R.drawable.ic_user_filled_custom, - onClick = onPromote - ) - } - - if (member.canResendInvite) { - this += ActionSheetItemData( - title = "Resend invitation", - iconRes = R.drawable.ic_mail, - onClick = onResendInvite, - qaTag = R.string.AccessibilityId_resendInvite, - ) - } - - if (BuildConfig.BUILD_TYPE != "release" && member.canResendPromotion) { - this += ActionSheetItemData( - title = "Resend promotion", - iconRes = R.drawable.ic_mail, - onClick = onResendPromotion, - qaTag = R.string.AccessibilityId_resendInvite, - ) - } - } - } - - ActionSheet( - items = options, - onDismissRequest = onDismissRequest - ) -} - -@Composable -fun EditMemberItem( - member: GroupMemberState, - onClick: (address: Address) -> Unit, - modifier: Modifier = Modifier -) { - MemberItem( - 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 = if(member.clickable) onClick else null, - modifier = modifier - ){ - if (member.canEdit) { - Icon( - painter = painterResource(R.drawable.ic_circle_dots_custom), - tint = LocalColors.current.text, - contentDescription = stringResource(R.string.AccessibilityId_sessionSettings) - ) - } - } -} - -@Preview -@Composable -private fun EditGroupPreviewSheet() { - PreviewTheme { - val oneMember = GroupMemberState( - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"), - name = "Test User", - avatarUIData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TOTO", - color = primaryBlue - ) - ) - ), - status = GroupMember.Status.INVITE_SENT, - highlightStatus = false, - canPromote = true, - canRemove = true, - canResendInvite = false, - canResendPromotion = false, - showAsAdmin = false, - showProBadge = true, - clickable = true, - statusLabel = "Invited" - ) - val twoMember = GroupMemberState( - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), - name = "Test User 2", - avatarUIData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TOTO", - color = primaryBlue - ) - ) - ), - status = GroupMember.Status.PROMOTION_FAILED, - highlightStatus = true, - canPromote = true, - canRemove = true, - canResendInvite = false, - canResendPromotion = false, - showAsAdmin = true, - showProBadge = true, - clickable = true, - statusLabel = "Promotion failed" - ) - val threeMember = GroupMemberState( - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), - name = "Test User 3", - avatarUIData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TOTO", - color = primaryBlue - ) - ) - ), - status = null, - highlightStatus = false, - canPromote = true, - canRemove = true, - canResendInvite = false, - canResendPromotion = false, - showAsAdmin = false, - showProBadge = false, - clickable = true, - statusLabel = "" - ) - - val (editingName, setEditingName) = remember { mutableStateOf(null) } - - EditGroup( - onBack = {}, - onAddMemberClick = {}, - onResendInviteClick = {}, - onPromoteClick = {}, - onRemoveClick = { _, _ -> }, - members = listOf(oneMember, twoMember, threeMember), - groupName = "Test ", - showAddMembers = true, - onResendPromotionClick = {}, - showingError = "Error", - onErrorDismissed = {}, - onMemberClicked = {}, - hideActionSheet = {}, - clickedMember = oneMember, - showLoading = false, - ) - } -} - - - -@Preview -@Composable -private fun EditGroupEditNamePreview( - @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors -) { - PreviewTheme(colors) { - val oneMember = GroupMemberState( - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"), - name = "Test User", - avatarUIData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TOTO", - color = primaryBlue - ) - ) - ), - status = GroupMember.Status.INVITE_SENT, - highlightStatus = false, - canPromote = true, - canRemove = true, - canResendInvite = false, - canResendPromotion = false, - showAsAdmin = false, - showProBadge = true, - clickable = true, - statusLabel = "Invited" - ) - val twoMember = GroupMemberState( - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), - name = "Test User 2", - avatarUIData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TOTO", - color = primaryBlue - ) - ) - ), - status = GroupMember.Status.PROMOTION_FAILED, - highlightStatus = true, - canPromote = true, - canRemove = true, - canResendInvite = false, - canResendPromotion = false, - showAsAdmin = true, - showProBadge = true, - clickable = true, - statusLabel = "Promotion failed" - ) - val threeMember = GroupMemberState( - accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), - name = "Test User 3", - avatarUIData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TOTO", - color = primaryBlue - ) - ) - ), - status = null, - highlightStatus = false, - canPromote = true, - canRemove = true, - canResendInvite = false, - canResendPromotion = false, - showAsAdmin = false, - showProBadge = false, - clickable = true, - statusLabel = "" - ) - - EditGroup( - onBack = {}, - onAddMemberClick = {}, - onResendInviteClick = {}, - onPromoteClick = {}, - onRemoveClick = { _, _ -> }, - members = listOf(oneMember, twoMember, threeMember), - groupName = "Test name that is very very long indeed because many words in it", - showAddMembers = true, - onResendPromotionClick = {}, - showingError = "Error", - onErrorDismissed = {}, - onMemberClicked = {}, - hideActionSheet = {}, - clickedMember = null, - showLoading = false, - ) - } -} \ 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 b0ece45a94..51db502057 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 @@ -88,12 +88,12 @@ fun InviteContacts( onToggleFooter: () -> Unit, onCloseFooter: () -> Unit, ) { - val colors = LocalColors.current + val trayItems = listOf( CollapsibleFooterItemData( label = GetString(LocalResources.current.getString(R.string.membersInvite)), buttonLabel = GetString(LocalResources.current.getString(R.string.membersInviteTitle)), - buttonColor = colors.accent, + isDanger = false, onClick = { onDoneClicked() } ) ) 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 new file mode 100644 index 0000000000..c4f5427c3b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/ManageGroupMembersScreen.kt @@ -0,0 +1,630 @@ +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 +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.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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 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 +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.CollapsibleFooterState +import org.thoughtcrime.securesms.groups.ManageGroupMembersViewModel.Commands.* +import org.thoughtcrime.securesms.ui.AlertDialog +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.DialogButtonData +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.RadioOption +import org.thoughtcrime.securesms.ui.SearchBarWithClose +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton +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 +import org.thoughtcrime.securesms.ui.theme.primaryBlue +import org.thoughtcrime.securesms.util.AvatarUIData +import org.thoughtcrime.securesms.util.AvatarUIElement + +@Composable +fun ManageGroupMembersScreen( + viewModel: ManageGroupMembersViewModel, + onBack: () -> Unit, +) { + ManageMembers( + onBack = onBack, + uiState = viewModel.uiState.collectAsState().value, + members = viewModel.nonAdminMembers.collectAsState().value, + hasMembers = viewModel.hasNonAdminMembers.collectAsState().value, + selectedMembers = viewModel.selectedMembers.collectAsState().value, + showAddMembers = viewModel.showAddMembers.collectAsState().value, + searchQuery = viewModel.searchQuery.collectAsState().value, + sendCommand = viewModel::onCommand, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManageMembers( + onBack: () -> Unit, + uiState: ManageGroupMembersViewModel.UiState, + searchQuery: String, + members: List, + hasMembers: Boolean = false, + selectedMembers: Set = emptySet(), + showAddMembers: Boolean, + sendCommand: (command: ManageGroupMembersViewModel.Commands) -> Unit, +) { + + val searchFocused = uiState.isSearchFocused + val showingError = uiState.error + val showingOngoingAction = uiState.ongoingAction + + 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.manageMembers), + 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) + ) { + + AnimatedVisibility( + // show only when add-members is enabled AND search is not focused + visible = showAddMembers && !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 + ) + ) { + Cell( + modifier = Modifier + .fillMaxWidth() + .padding(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() + } + } + } + } + + if (hasMembers) { + if (!searchFocused) { + Text( + modifier = Modifier.padding( + start = LocalDimensions.current.mediumSpacing, + bottom = LocalDimensions.current.smallSpacing + ), + text = LocalResources.current.getString(R.string.membersNonAdmins), + 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(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) + ) + } + } + } else { + Text( + modifier = Modifier + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + text = LocalResources.current.getString(R.string.NoNonAdminsInGroup), + textAlign = TextAlign.Center, + style = LocalType.current.base, + color = LocalColors.current.textSecondary + ) + } + } + } + + if (uiState.removeMembersDialog.visible) { + ShowRemoveMembersDialog( + state = uiState.removeMembersDialog, + sendCommand = sendCommand + ) + } + + 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( + state: ManageGroupMembersViewModel.RemoveMembersDialogState, + modifier: Modifier = Modifier, + sendCommand: (ManageGroupMembersViewModel.Commands) -> Unit +) { + var deleteMessages by remember { mutableStateOf(false) } + + AlertDialog( + modifier = modifier, + onDismissRequest = { + // hide dialog + sendCommand(DismissRemoveDialog) + }, + title = annotatedStringResource(R.string.remove), + text = annotatedStringResource(state.removeMemberBody), + content = { + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(state.removeMemberText), + selected = !deleteMessages + ) + ) { + deleteMessages = false + } + + DialogTitledRadioButton( + option = RadioOption( + value = Unit, + title = GetString(state.removeMessagesText), + selected = deleteMessages, + ) + ) { + deleteMessages = true + } + }, + buttons = listOf( + DialogButtonData( + text = GetString(stringResource(id = R.string.remove)), + color = LocalColors.current.danger, + dismissOnClick = false, + onClick = { + sendCommand(DismissRemoveDialog) + sendCommand(RemoveMembers(deleteMessages)) + } + ), + DialogButtonData( + text = GetString(stringResource(R.string.cancel)), + onClick = { + sendCommand(DismissRemoveDialog) + } + ) + ) + ) +} + +@Preview +@Composable +private fun EditGroupPreviewSheet() { + val title = GetString("3 Members Selected") + + // build tray items + val trayItems = listOf( + CollapsibleFooterItemData( + label = GetString("Reseaand"), + buttonLabel = GetString("Resend"), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Remove"), + buttonLabel = GetString("Remove"), + isDanger = true, + onClick = { } + ) + ) + + PreviewTheme { + val oneMember = GroupMemberState( + accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"), + name = "Test User", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + status = GroupMember.Status.INVITE_SENT, + highlightStatus = false, + canPromote = true, + canRemove = true, + canResendInvite = false, + canResendPromotion = false, + showAsAdmin = false, + showProBadge = true, + clickable = true, + statusLabel = "Invited", + ) + val twoMember = GroupMemberState( + accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), + name = "Test User 2", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + status = GroupMember.Status.PROMOTION_FAILED, + highlightStatus = true, + canPromote = true, + canRemove = true, + canResendInvite = false, + canResendPromotion = false, + showAsAdmin = true, + showProBadge = true, + clickable = true, + statusLabel = "Promotion failed" + ) + val threeMember = GroupMemberState( + accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), + name = "Test User 3", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + status = null, + highlightStatus = false, + canPromote = true, + canRemove = true, + canResendInvite = false, + canResendPromotion = false, + showAsAdmin = false, + showProBadge = false, + clickable = true, + statusLabel = "" + ) + + val (_, _) = remember { mutableStateOf(null) } + + ManageMembers( + onBack = {}, + members = listOf(oneMember, twoMember, threeMember), + showAddMembers = true, + searchQuery = "Test", + selectedMembers = emptySet(), + sendCommand = {}, + uiState = ManageGroupMembersViewModel.UiState( + options = emptyList(), + footer = CollapsibleFooterState( + visible = true, + collapsed = false, + footerActionTitle = title, + footerActionItems = trayItems + ) + ), + hasMembers = true, + ) + } +} + +@Preview +@Composable +private fun EditGroupEditNamePreview( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + val oneMember = GroupMemberState( + accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"), + name = "Test User", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + status = GroupMember.Status.INVITE_SENT, + highlightStatus = false, + canPromote = true, + canRemove = true, + canResendInvite = false, + canResendPromotion = false, + showAsAdmin = false, + showProBadge = true, + clickable = true, + statusLabel = "Invited", + ) + val twoMember = GroupMemberState( + accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1235"), + name = "Test User 2", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + status = GroupMember.Status.PROMOTION_FAILED, + highlightStatus = true, + canPromote = true, + canRemove = true, + canResendInvite = false, + canResendPromotion = false, + showAsAdmin = true, + showProBadge = true, + clickable = true, + statusLabel = "Promotion failed" + ) + val threeMember = GroupMemberState( + accountId = AccountId("05abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1236"), + name = "Test User 3", + avatarUIData = AvatarUIData( + listOf( + AvatarUIElement( + name = "TOTO", + color = primaryBlue + ) + ) + ), + status = null, + highlightStatus = false, + canPromote = true, + canRemove = true, + canResendInvite = false, + canResendPromotion = false, + showAsAdmin = false, + showProBadge = false, + clickable = true, + statusLabel = "" + ) + + ManageMembers( + onBack = {}, + members = listOf(oneMember, twoMember, threeMember), + showAddMembers = true, + searchQuery = "", + selectedMembers = emptySet(), + sendCommand = {}, + uiState = ManageGroupMembersViewModel.UiState( + options = emptyList(), + footer = CollapsibleFooterState( + visible = true, + collapsed = false, + footerActionTitle = GetString("3 Members Selected"), + footerActionItems = listOf( + CollapsibleFooterItemData( + label = GetString("Resend"), + buttonLabel = GetString("1"), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Remove"), + buttonLabel = GetString("1"), + isDanger = true, + onClick = { } + ) + ) + )), + hasMembers = true, + ) + } +} + +@Preview +@Composable +private fun EditGroupEmptyPreview( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ManageMembers( + onBack = {}, + members = listOf(), + showAddMembers = true, + searchQuery = "", + selectedMembers = emptySet(), + sendCommand = {}, + uiState = ManageGroupMembersViewModel.UiState( + options = emptyList(), + footer = CollapsibleFooterState( + visible = false, + collapsed = true, + footerActionTitle = GetString("3 Members Selected"), + footerActionItems = listOf( + CollapsibleFooterItemData( + label = GetString("Resend"), + buttonLabel = GetString("1"), + isDanger = false, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Remove"), + buttonLabel = GetString("1"), + isDanger = true, + onClick = { } + ) + ) + )), + hasMembers = true, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt index f45bad3bfc..03df3cb549 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt @@ -124,8 +124,6 @@ fun CreateGroup( modifier = modifier.padding(paddings).consumeWindowInsets(paddings), horizontalAlignment = Alignment.CenterHorizontally, ) { - GroupMinimumVersionBanner() - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) SessionOutlinedTextField( diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 5b10c2c273..3cdb0267d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -4,8 +4,6 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade -import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.FastOutSlowInEasing @@ -36,14 +34,12 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn @@ -79,6 +75,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -86,6 +83,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.BlendMode @@ -103,6 +102,9 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -705,7 +707,7 @@ fun SearchBar( modifier: Modifier = Modifier, placeholder: String? = null, enabled: Boolean = true, - backgroundColor: Color = LocalColors.current.background + backgroundColor: Color = LocalColors.current.background, ) { BasicTextField( singleLine = true, @@ -771,6 +773,75 @@ fun SearchBar( ) } +/** + * Search with the close action for removing focus + */ + +@Composable +fun SearchBarWithClose( + query: String, + onValueChanged: (String) -> Unit, + onClear: () -> Unit, + isFocused: Boolean, + onFocusChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, + placeholder: String? = null, + enabled: Boolean = true, + backgroundColor: Color = LocalColors.current.backgroundSecondary, +) { + + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + + // When the parent toggles isFocused, request or clear focus accordingly + LaunchedEffect(isFocused) { + if (isFocused) { + focusRequester.requestFocus() + keyboard?.show() + } else { + focusManager.clearFocus(force = true) + keyboard?.hide() + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) + + ) { + SearchBar( + query = query, + onValueChanged = onValueChanged, + onClear = onClear, + placeholder = placeholder, + enabled = enabled, + backgroundColor = backgroundColor, + modifier = Modifier + .weight(1f) + .background(backgroundColor, MaterialTheme.shapes.small) + .onFocusChanged { onFocusChanged(it.isFocused) } + ) + + // Right-side Cancel (outside the search field) + AnimatedVisibility(visible = isFocused) { + Text( + text = LocalResources.current.getString(R.string.close), + style = LocalType.current.base, + color = LocalColors.current.text, + modifier = Modifier + .clickable { + focusManager.clearFocus(force = true) + } + .padding( + vertical = LocalDimensions.current.xxsSpacing + ) + ) + } + } +} + /** * CollapsibleFooterAction */ @@ -899,20 +970,20 @@ private fun CollapsibleFooterActions( val capDp = with(density) { capPx.toDp() } val single = items.size == 1 - val measuredMaxButtonWidthPx = remember(items, capPx) { mutableIntStateOf(1) } + var equalWidthPx by rememberSaveable(capPx) { mutableIntStateOf(-1) } // Only do the offscreen equal width computation when we have 2+ buttons. if (!single) { SubcomposeLayout { parentConstraints -> val measurables = subcompose("measureButtons") { items.forEach { item -> - SlimFillButtonRect(item.buttonLabel.string(), color = item.buttonColor) {} + SlimFillButtonRect(item.buttonLabel.string(), color = LocalColors.current.accent) {} } } val placeables = measurables.map { m -> m.measure( Constraints( - minWidth = 1, + minWidth = 0, maxWidth = capPx, minHeight = 0, maxHeight = parentConstraints.maxHeight @@ -920,13 +991,12 @@ private fun CollapsibleFooterActions( ) } val natural = placeables.maxOfOrNull { it.width } ?: 1 - measuredMaxButtonWidthPx.intValue = natural.coerceIn(1, capPx) + equalWidthPx = natural.coerceIn(0, capPx) + layout(0, 0) {} } } - val equalWidthDp = with(density) { measuredMaxButtonWidthPx.intValue.toDp() } - Column( modifier = Modifier .fillMaxWidth() @@ -944,18 +1014,27 @@ private fun CollapsibleFooterActions( onClick = {}, qaTag = R.string.qa_collapsing_footer_action, endContent = { + val widthMod = + if (single) { + Modifier + .wrapContentWidth() + .widthIn(max = capDp) + } else if (equalWidthPx >= 0) { + Modifier.width(with(density) { equalWidthPx.toDp() }) + } else { + Modifier + .wrapContentWidth() + .widthIn(max = capDp) + } Box( modifier = Modifier .padding(start = LocalDimensions.current.smallSpacing) - .then( - if (single) Modifier.wrapContentWidth().widthIn(max = capDp) - else Modifier.width(equalWidthDp) - ) + .then(widthMod) ) { SlimFillButtonRect( modifier = if (single) Modifier else Modifier.fillMaxWidth(), text = item.buttonLabel.string(), - color = item.buttonColor + color = if(item.isDanger) LocalColors.current.danger else LocalColors.current.accent ) { item.onClick() } } } @@ -975,7 +1054,7 @@ data class CollapsibleFooterActionData( data class CollapsibleFooterItemData( val label: GetString, val buttonLabel: GetString, - val buttonColor: Color, + val isDanger: Boolean, val onClick: () -> Unit ) @@ -990,13 +1069,13 @@ fun PreviewCollapsibleActionTray( CollapsibleFooterItemData( label = GetString("Invite "), buttonLabel = GetString("Invite"), - buttonColor = LocalColors.current.accent, + isDanger = false, onClick = {} ), CollapsibleFooterItemData( label = GetString("Delete"), buttonLabel = GetString("2"), - buttonColor = LocalColors.current.danger, + isDanger = true, onClick = {} ) ) @@ -1022,13 +1101,13 @@ fun PreviewCollapsibleActionTrayLongText( CollapsibleFooterItemData( label = GetString("Looooooooooooooooooooooooooooooooooooooooooooooooooooooooong"), buttonLabel = GetString("Long Looooooooooooooooooooong"), - buttonColor = LocalColors.current.accent, + isDanger = false, onClick = {} ), CollapsibleFooterItemData( label = GetString("Delete"), buttonLabel = GetString("Delete"), - buttonColor = LocalColors.current.danger, + isDanger = true, onClick = {} ) ) @@ -1493,4 +1572,23 @@ fun PreviewActionRowItems() { ) } } +} + + +@Preview +@Composable +fun PreviewSearchWithCancel( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + SearchBarWithClose( + query = "Test Query", + onValueChanged = { }, + onClear = { }, + placeholder = "Search", + enabled = true, + isFocused = true, + onFocusChanged = {} + ) + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_admin_custom.xml b/app/src/main/res/drawable/ic_add_admin_custom.xml new file mode 100644 index 0000000000..675fbac6e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_admin_custom.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_user_round_search.xml b/app/src/main/res/drawable/ic_user_round_search.xml new file mode 100644 index 0000000000..df48dae1b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_round_search.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index a727f999e5..e86504c76a 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -126,6 +126,7 @@ hide-nts-menu-option copy-community-url-menu-option leave-community-menu-option + manage-admins-menu-option manage-members-menu-option group-members-menu-option invite-contacts-menu-option