Add group terminate support.

This commit is contained in:
Cody Henthorne
2026-03-19 16:10:26 -04:00
parent 0896718e5c
commit a0c0acb8fc
130 changed files with 1312 additions and 146 deletions

View File

@@ -20,6 +20,7 @@ import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.view.doOnPreDraw
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.Navigation
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
@@ -28,6 +29,7 @@ import com.google.android.flexbox.FlexboxLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.coroutines.launch
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.DimensionUnit
import org.signal.core.util.Result
@@ -48,6 +50,7 @@ import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
@@ -67,6 +70,7 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.L
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.SharedMediaPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.TerminatedBannerPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
@@ -74,6 +78,7 @@ import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelEducationSheet
import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel
import org.thoughtcrime.securesms.groups.ui.EndGroupDialog
import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
@@ -147,6 +152,12 @@ class ConversationSettingsFragment :
}
}
private val endGroupIcon by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_x_circle_24).apply {
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
}
}
private val viewModel by viewModels<ConversationSettingsViewModel>(
factoryProducer = {
val groupId = args.groupId as? GroupId
@@ -268,6 +279,7 @@ class ConversationSettingsFragment :
InternalPreference.register(adapter)
GroupDescriptionPreference.register(adapter)
LegacyGroupPreference.register(adapter)
TerminatedBannerPreference.register(adapter)
CallPreference.register(adapter)
val recipientId = args.recipientId
@@ -330,10 +342,17 @@ class ConversationSettingsFragment :
return@configure
}
state.withGroupSettingsState {
if (it.isTerminated) {
customPref(TerminatedBannerPreference.Model())
}
}
customPref(
AvatarPreference.Model(
recipient = state.recipient,
storyViewState = state.storyViewState,
reduceTopMargin = state.isTerminatedGroup,
onAvatarClick = { avatar ->
val viewAvatarIntent = AvatarPreviewActivity.intentFromRecipientId(requireContext(), state.recipient.id)
val viewAvatarTransitionBundle = AvatarPreviewActivity.createTransitionBundle(requireActivity(), avatar)
@@ -392,7 +411,7 @@ class ConversationSettingsFragment :
)
)
if (groupState.groupId.isV2) {
if (groupState.groupId.isV2 && !groupState.isTerminated) {
customPref(
GroupDescriptionPreference.Model(
groupId = groupState.groupId,
@@ -793,10 +812,15 @@ class ConversationSettingsFragment :
if (groupState.canAddToGroup || memberCount > 0) {
dividerPref()
sectionHeaderPref(DSLSettingsText.from(resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount)))
val memberHeaderText = if (groupState.isTerminated) {
resources.getQuantityString(R.plurals.ConversationSettingsFragment__d_former_members, memberCount, memberCount)
} else {
resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount)
}
sectionHeaderPref(DSLSettingsText.from(memberHeaderText))
}
if (groupState.canAddToGroup && !state.isDeprecatedOrUnregistered) {
if (groupState.canAddToGroup && !groupState.isTerminated && !state.isDeprecatedOrUnregistered) {
customPref(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_members),
@@ -851,14 +875,14 @@ class ConversationSettingsFragment :
)
}
if (state.recipient.isPushV2Group) {
if (state.recipient.isPushV2Group && !groupState.isTerminated) {
dividerPref()
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_link),
summary = DSLSettingsText.from(if (groupState.groupLinkEnabled) R.string.preferences_on else R.string.preferences_off),
icon = DSLSettingsIcon.from(R.drawable.ic_link_16),
isEnabled = !state.isDeprecatedOrUnregistered,
isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered,
onClick = {
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToShareableGroupLinkFragment(groupState.groupId.requireV2().toString()))
}
@@ -880,7 +904,7 @@ class ConversationSettingsFragment :
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__requests_and_invites),
icon = DSLSettingsIcon.from(R.drawable.ic_update_group_add_16),
isEnabled = !state.isDeprecatedOrUnregistered,
isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered,
onClick = {
startActivity(ManagePendingAndRequestingMembersActivity.newIntent(requireContext(), groupState.groupId.requireV2()))
}
@@ -890,7 +914,7 @@ class ConversationSettingsFragment :
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__permissions),
icon = DSLSettingsIcon.from(R.drawable.ic_lock_24),
isEnabled = !state.isDeprecatedOrUnregistered,
isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToPermissionsSettingsFragment(groupState.groupId)
navController.safeNavigate(action)
@@ -913,7 +937,46 @@ class ConversationSettingsFragment :
}
}
if (state.canModifyBlockedState) {
state.withGroupSettingsState { groupState ->
if (groupState.isTerminated) {
dividerPref()
if (state.isArchived) {
clickPref(
title = DSLSettingsText.from(R.string.ConversationListFragment_unarchive),
icon = DSLSettingsIcon.from(R.drawable.symbol_archive_up_24),
onClick = {
viewModel.toggleArchive()
}
)
} else {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__archive_chat),
icon = DSLSettingsIcon.from(R.drawable.symbol_archive_24),
onClick = {
viewModel.toggleArchive()
onToolbarNavigationClicked()
}
)
}
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__delete_chat, alertTint),
icon = DSLSettingsIcon.from(CoreUiR.drawable.symbol_trash_24, R.color.signal_alert_primary),
onClick = {
val progressDialog = ProgressCardDialogFragment.create(getString(R.string.ConversationFragment_deleting_messages))
progressDialog.show(parentFragmentManager, null)
lifecycleScope.launch {
viewModel.deleteChat()
progressDialog.dismissAllowingStateLoss()
onToolbarNavigationClicked()
}
}
)
}
}
if (state.canModifyBlockedState && !state.isTerminatedGroup) {
state.withRecipientSettingsState {
dividerPref()
}
@@ -1005,6 +1068,50 @@ class ConversationSettingsFragment :
)
}
}
state.withGroupSettingsState { groupState ->
if (groupState.isTerminated) {
dividerPref()
val reportSpamTint = R.color.signal_alert_primary
clickPref(
title = DSLSettingsText.from(R.string.ConversationFragment_report_spam, ContextCompat.getColor(requireContext(), reportSpamTint)),
icon = DSLSettingsIcon.from(R.drawable.symbol_spam_24, reportSpamTint),
onClick = {
BlockUnblockDialog.showReportSpamFor(
requireContext(),
viewLifecycleOwner.lifecycle,
state.recipient,
{
viewModel
.onReportSpam()
.subscribeBy {
Toast.makeText(requireContext(), R.string.ConversationFragment_reported_as_spam, Toast.LENGTH_SHORT).show()
onToolbarNavigationClicked()
}
.addTo(lifecycleDisposable)
},
null
)
}
)
}
}
state.withGroupSettingsState { groupState ->
if (groupState.canEndGroup && RemoteConfig.groupTerminateSend) {
dividerPref()
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__end_group, if (state.isDeprecatedOrUnregistered) alertDisabledTint else alertTint),
icon = DSLSettingsIcon.from(endGroupIcon),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
EndGroupDialog.show(requireActivity(), groupState.groupId.requireV2(), groupState.groupTitle)
}
)
}
}
}
}

View File

@@ -222,9 +222,18 @@ class ConversationSettingsRepository(
return liveGroup.getMembershipCountDescription(context.resources)
}
fun getExternalPossiblyMigratedGroupRecipientId(groupId: GroupId, consumer: (RecipientId) -> Unit) {
SignalExecutors.BOUNDED.execute {
consumer(Recipient.externalPossiblyMigratedGroup(groupId).id)
}
@WorkerThread
fun isArchived(recipientId: RecipientId): Boolean {
return SignalDatabase.threads.isArchived(recipientId)
}
@WorkerThread
fun setArchived(threadId: Long, archived: Boolean) {
SignalDatabase.threads.setArchived(setOf(threadId), archived)
}
@WorkerThread
fun deleteChat(threadId: Long) {
SignalDatabase.threads.deleteConversation(threadId)
}
}

View File

@@ -20,6 +20,7 @@ data class ConversationSettingsState(
val buttonStripState: ButtonStripPreference.State = ButtonStripPreference.State(),
val disappearingMessagesLifespan: Int = 0,
val canModifyBlockedState: Boolean = false,
val isArchived: Boolean = false,
val sharedMedia: List<MediaTable.MediaRecord> = emptyList(),
val sharedMediaIds: List<Long> = listOf(),
val displayInternalRecipientDetails: Boolean = false,
@@ -29,6 +30,7 @@ data class ConversationSettingsState(
) {
val isLoaded: Boolean = recipient != Recipient.UNKNOWN && sharedMediaLoaded && specificSettingsState.isLoaded
val isTerminatedGroup: Boolean = (specificSettingsState as? SpecificSettingsState.GroupSettingsState)?.isTerminated == true
fun withRecipientSettingsState(consumer: (SpecificSettingsState.RecipientSettingsState) -> Unit) {
if (specificSettingsState is SpecificSettingsState.RecipientSettingsState) {
@@ -72,6 +74,8 @@ sealed class SpecificSettingsState {
val isSelfAdmin: Boolean = false,
val canAddToGroup: Boolean = false,
val canEditGroupAttributes: Boolean = false,
val isActive: Boolean = false,
val isTerminated: Boolean = false,
val canLeave: Boolean = false,
val canShowMoreGroupMembers: Boolean = false,
val groupMembersExpanded: Boolean = false,
@@ -88,6 +92,8 @@ sealed class SpecificSettingsState {
val canSetOwnMemberLabel: Boolean = false
) : SpecificSettingsState() {
val canEndGroup: Boolean get() = isActive && groupId.isV2 && isSelfAdmin
override val isLoaded: Boolean = groupTitleLoaded && groupDescriptionLoaded
override fun requireGroupSettingsState(): GroupSettingsState = this

View File

@@ -144,6 +144,26 @@ sealed class ConversationSettingsViewModel(
disposable.clear()
}
fun toggleArchive() {
val state = store.state
if (state.threadId > 0) {
val newArchived = !state.isArchived
store.update { it.copy(isArchived = newArchived) }
viewModelScope.launch(SignalDispatchers.IO) {
repository.setArchived(state.threadId, newArchived)
}
}
}
suspend fun deleteChat() {
withContext(SignalDispatchers.IO) {
val threadId = store.state.threadId
if (threadId > 0) {
repository.deleteChat(threadId)
}
}
}
private class RecipientSettingsViewModel(
private val recipientId: RecipientId,
private val callMessageIds: LongArray,
@@ -299,21 +319,21 @@ sealed class ConversationSettingsViewModel(
store.update { it.copy(storyViewState = storyViewState) }
}
val recipientAndIsActive = LiveDataUtil.combineLatest(liveGroup.groupRecipient, liveGroup.isActive) { r, a -> r to a }
store.update(recipientAndIsActive) { (recipient, isActive), state ->
store.update(liveGroup.groupRecipient) { recipient, state ->
state.copy(
recipient = recipient,
buttonStripState = ButtonStripPreference.State(
isMessageAvailable = callMessageIds.isNotEmpty(),
isVideoAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive,
isVideoAvailable = recipient.isPushV2Group && !recipient.isBlocked && recipient.isActiveGroup,
isAudioAvailable = false,
isAudioSecure = recipient.isPushV2Group,
isMuted = recipient.isMuted,
isMuteAvailable = true,
isSearchAvailable = callMessageIds.isEmpty(),
isAddToStoryAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive && !SignalStore.story.isFeatureDisabled
isAddToStoryAvailable = recipient.isPushV2Group && !recipient.isBlocked && recipient.isActiveGroup && !SignalStore.story.isFeatureDisabled
),
canModifyBlockedState = RecipientUtil.isBlockable(recipient),
isArchived = repository.isArchived(recipient.id),
specificSettingsState = state.requireGroupSettingsState().copy(
legacyGroupState = getLegacyGroupState()
)
@@ -398,11 +418,20 @@ sealed class ConversationSettingsViewModel(
store.update(liveGroup.isActive) { isActive, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(
isActive = isActive,
canLeave = isActive && groupId.isPush
)
)
}
store.update(liveGroup.isTerminated) { isTerminated, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(
isTerminated = isTerminated
)
)
}
store.update(liveGroup.title) { title, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(

View File

@@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.groups.GroupAccessControl
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.LiveGroup
import org.thoughtcrime.securesms.util.SingleLiveEvent
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import org.thoughtcrime.securesms.util.livedata.Store
class PermissionsSettingsViewModel(
@@ -22,8 +23,8 @@ class PermissionsSettingsViewModel(
val events: LiveData<PermissionsSettingsEvents> = internalEvents
init {
store.update(liveGroup.isSelfAdmin) { isSelfAdmin, state ->
state.copy(selfCanEditSettings = isSelfAdmin)
store.update(LiveDataUtil.combineLatest(liveGroup.isSelfAdmin, liveGroup.isActive) { admin, active -> admin && active }) { canEdit, state ->
state.copy(selfCanEditSettings = canEdit)
}
store.update(liveGroup.membershipAdditionAccessControl) { membershipAdditionAccessControl, state ->

View File

@@ -1,7 +1,9 @@
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.view.View
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.view.AvatarView
import org.thoughtcrime.securesms.badges.BadgeImageView
@@ -25,6 +27,7 @@ object AvatarPreference {
class Model(
val recipient: Recipient,
val storyViewState: StoryViewState,
val reduceTopMargin: Boolean = false,
val onAvatarClick: (AvatarView) -> Unit,
val onBadgeClick: (Badge) -> Unit
) : PreferenceModel<Model>() {
@@ -35,7 +38,8 @@ object AvatarPreference {
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) &&
recipient.hasSameContent(newItem.recipient) &&
storyViewState == newItem.storyViewState
storyViewState == newItem.storyViewState &&
reduceTopMargin == newItem.reduceTopMargin
}
}
@@ -49,6 +53,10 @@ object AvatarPreference {
}
override fun bind(model: Model) {
(itemView.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
it.topMargin = if (model.reduceTopMargin) 0.dp else 40.dp
}
if (model.recipient.isSelf) {
badge.setBadge(null)
badge.setOnClickListener(null)

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
object TerminatedBannerPreference {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.conversation_settings_terminated_banner))
}
class Model : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean = true
override fun areContentsTheSame(newItem: Model): Boolean = true
}
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
override fun bind(model: Model) = Unit
}
}