diff --git a/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsNavigation.kt b/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsNavigation.kt index 1dd95827e6..b295bca836 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsNavigation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsNavigation.kt @@ -57,7 +57,9 @@ fun EntryProviderScope.chatsNavEntries( MessageDetailsEntry(route) } - entry { route -> + entry( + metadata = TransitionSpecs.FadeScale.metadata + ) { route -> ConversationSettingsEntry(route) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiText.kt b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiText.kt new file mode 100644 index 0000000000..cf560dc3db --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiText.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.emoji + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow + +/** [Text] that renders any emoji in [text] as inline content. */ +@Composable +fun EmojiText( + text: String, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.bodyLarge, + color: Color = MaterialTheme.colorScheme.onSurface, + textAlign: TextAlign? = null, + maxLines: Int = Int.MAX_VALUE +) { + Emojifier(text = text) { annotatedText, inlineContent -> + Text( + text = annotatedText, + inlineContent = inlineContent, + style = style, + color = color, + textAlign = textAlign, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/CallRowResources.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/CallRowResources.kt new file mode 100644 index 0000000000..ad747343ba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/CallRowResources.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.CallTable +import org.thoughtcrime.securesms.database.MessageTypes + +/** + * Maps a call to the icon and label that [callLogSection] shows for it. + */ +object CallRowResources { + + @DrawableRes + fun iconRes(call: CallTable.Call): Int { + return when (call.messageType) { + MessageTypes.MISSED_VIDEO_CALL_TYPE, MessageTypes.MISSED_AUDIO_CALL_TYPE -> { + R.drawable.symbol_missed_incoming_24 + } + MessageTypes.INCOMING_AUDIO_CALL_TYPE, MessageTypes.INCOMING_VIDEO_CALL_TYPE -> { + if (call.isDisplayedAsMissedCallInUi) R.drawable.symbol_missed_incoming_24 else R.drawable.symbol_arrow_downleft_24 + } + MessageTypes.OUTGOING_AUDIO_CALL_TYPE, MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> { + R.drawable.symbol_arrow_upright_24 + } + MessageTypes.GROUP_CALL_TYPE -> { + when { + call.isDisplayedAsMissedCallInUi -> R.drawable.symbol_missed_incoming_24 + call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.drawable.symbol_group_24 + call.direction == CallTable.Direction.INCOMING -> R.drawable.symbol_arrow_downleft_24 + call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_24 + else -> error("Unexpected group call state: event=${call.event}, direction=${call.direction}") + } + } + else -> { + error("Unexpected type ${call.type}") + } + } + } + + @StringRes + fun typeStringRes(call: CallTable.Call): Int { + return when (call.messageType) { + MessageTypes.MISSED_AUDIO_CALL_TYPE -> { + missedCallStringRes(isVideo = false, callEvent = call.event) + } + MessageTypes.MISSED_VIDEO_CALL_TYPE -> { + missedCallStringRes(isVideo = true, callEvent = call.event) + } + MessageTypes.INCOMING_AUDIO_CALL_TYPE -> { + if (call.isDisplayedAsMissedCallInUi) missedCallStringRes(false, call.event) else R.string.MessageRecord_incoming_voice_call + } + MessageTypes.INCOMING_VIDEO_CALL_TYPE -> { + if (call.isDisplayedAsMissedCallInUi) missedCallStringRes(true, call.event) else R.string.MessageRecord_incoming_video_call + } + MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> { + if (call.event == CallTable.Event.NOT_ACCEPTED) R.string.MessageRecord_unanswered_voice_call else R.string.MessageRecord_outgoing_voice_call + } + MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> { + if (call.event == CallTable.Event.NOT_ACCEPTED) R.string.MessageRecord_unanswered_video_call else R.string.MessageRecord_outgoing_video_call + } + MessageTypes.GROUP_CALL_TYPE -> { + when { + call.isDisplayedAsMissedCallInUi -> { + if (call.event == CallTable.Event.MISSED_NOTIFICATION_PROFILE) { + R.string.CallPreference__missed_group_call_notification_profile + } else { + R.string.CallPreference__missed_group_call + } + } + call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.string.CallPreference__group_call + call.direction == CallTable.Direction.INCOMING -> R.string.CallPreference__incoming_group_call + call.direction == CallTable.Direction.OUTGOING -> R.string.CallPreference__outgoing_group_call + else -> error("Unexpected group call state: event=${call.event}, direction=${call.direction}") + } + } + else -> { + error("Unexpected type ${call.messageType}") + } + } + } + + @StringRes + private fun missedCallStringRes(isVideo: Boolean, callEvent: CallTable.Event): Int { + return when (callEvent) { + CallTable.Event.MISSED_NOTIFICATION_PROFILE -> { + if (isVideo) R.string.MessageRecord_missed_video_call_notification_profile else R.string.MessageRecord_missed_voice_call_notification_profile + } + CallTable.Event.NOT_ACCEPTED -> { + if (isVideo) R.string.MessageRecord_declined_video_call else R.string.MessageRecord_declined_voice_call + } + else -> { + if (isVideo) R.string.MessageRecord_missed_video_call else R.string.MessageRecord_missed_voice_call + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsAction.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsAction.kt new file mode 100644 index 0000000000..9e47eeee09 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsAction.kt @@ -0,0 +1,217 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation + +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.database.MediaTable +import org.thoughtcrime.securesms.database.model.IdentityRecord +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.SelectionLimits +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * One-shot side effects that need an Activity, FragmentManager, or the legacy nav graph, and therefore have to be + * carried out by [ConversationSettingsFragment] rather than the screen itself. + * + * Actions are logged, so be sure `toString()` contains nothing sensitive. + */ +sealed interface ConversationSettingsAction { + + /** Open the full-screen preview of the recipient's avatar. */ + data class ShowAvatarPreview(val recipientId: RecipientId) : ConversationSettingsAction + + /** Ask whether to view the recipient's story or their avatar, since they have both. */ + data class ShowStoryOrAvatarDialog(val recipientId: RecipientId, val isInHiddenStoryMode: Boolean) : ConversationSettingsAction + + /** Open the sheet describing the badge the user tapped. */ + data class ShowBadgeSheet(val recipientId: RecipientId, val badge: Badge) : ConversationSettingsAction { + override fun toString(): String = "ShowBadgeSheet($recipientId)" + } + + /** Open the sheet describing the recipient. */ + data class ShowAboutSheet(val recipient: Recipient) : ConversationSettingsAction { + override fun toString(): String = "ShowAboutSheet(${recipient.id})" + } + + /** Open the flow for editing the group's name, avatar, and description. */ + data class EditGroupProfile(val groupId: GroupId) : ConversationSettingsAction + + /** Open the flow for editing just the group's description. */ + data class EditGroupDescription(val groupId: GroupId) : ConversationSettingsAction + + /** Show the group's full description in a dialog, since it was too long to fit in the header. */ + data class ShowGroupDescriptionDialog(val groupId: GroupId, val shouldLinkify: Boolean) : ConversationSettingsAction + + /** Open the support page explaining Signal groups. */ + data object ShowGroupsLearnMore : ConversationSettingsAction + + /** Open the share sheet for inviting friends to Signal. */ + data object ShowInviteFriends : ConversationSettingsAction + + /** Open the internal details screen, which only internal users ever see. */ + data class NavigateToInternalDetails(val recipientId: RecipientId) : ConversationSettingsAction + + /** Open the conversation, optionally with search already running. */ + data class OpenConversation(val recipientId: RecipientId, val threadId: Long, val withSearchOpen: Boolean = false) : ConversationSettingsAction + + /** Open the flow for adding to this group's story. */ + data class AddToGroupStory(val recipientId: RecipientId) : ConversationSettingsAction + + /** Start a video call with the recipient. */ + data class StartVideoCall(val recipient: Recipient) : ConversationSettingsAction { + override fun toString(): String = "StartVideoCall(${recipient.id})" + } + + /** Start an audio call with the recipient. */ + data class StartAudioCall(val recipient: Recipient) : ConversationSettingsAction { + override fun toString(): String = "StartAudioCall(${recipient.id})" + } + + /** Prompt for the custom time the user wants to stay muted until. */ + data object ShowMuteUntilTimePicker : ConversationSettingsAction + + /** Open the disappearing messages screen. */ + data class NavigateToDisappearingMessages(val recipientId: RecipientId, val initialValue: Int) : ConversationSettingsAction + + /** Open the flow for editing the recipient's nickname and note. */ + data class EditNickname(val recipientId: RecipientId) : ConversationSettingsAction + + /** Open the chat color and wallpaper screen. */ + data class OpenChatWallpaper(val recipientId: RecipientId) : ConversationSettingsAction + + /** Open the sounds and notifications screen. */ + data class NavigateToSoundsAndNotifications(val recipientId: RecipientId, val useInternalScreen: Boolean) : ConversationSettingsAction + + /** Open the list of starred messages in this chat. */ + data class OpenStarredMessages(val threadId: Long) : ConversationSettingsAction + + /** Open the recipient's entry in the system contacts. */ + data class ViewContact(val recipient: Recipient) : ConversationSettingsAction { + override fun toString(): String = "ViewContact(${recipient.id})" + } + + /** Open the system flow for adding the recipient to the contacts. */ + data class AddContact(val recipient: Recipient) : ConversationSettingsAction { + override fun toString(): String = "AddContact(${recipient.id})" + } + + /** Open the safety number screen for the recipient. */ + data class ShowSafetyNumber(val identityRecord: IdentityRecord?) : ConversationSettingsAction { + override fun toString(): String = "ShowSafetyNumber(hasIdentityRecord=${identityRecord != null})" + } + + /** Open the media viewer on the item the user tapped in the shared media rail. */ + data class ShowMediaPreview(val mediaRecord: MediaTable.MediaRecord, val isLtr: Boolean) : ConversationSettingsAction { + override fun toString(): String = "ShowMediaPreview(messageId=${mediaRecord.messageId}, isLtr=$isLtr)" + } + + /** Download the media the user tapped, since it isn't on disk yet. */ + data class DownloadMedia(val mediaRecord: MediaTable.MediaRecord) : ConversationSettingsAction { + override fun toString(): String = "DownloadMedia(messageId=${mediaRecord.messageId})" + } + + /** Tell the user the media they tapped hasn't finished sending. */ + data object ShowMediaNotSentYet : ConversationSettingsAction + + /** Open the media overview for this chat. */ + data class ShowMediaOverview(val threadId: Long) : ConversationSettingsAction + + /** Open the Signal support center. */ + data object OpenSupportCenter : ConversationSettingsAction + + /** Open the flow for contacting Signal support. */ + data object OpenContactUs : ConversationSettingsAction + + /** Open the donation flow. */ + data object OpenDonate : ConversationSettingsAction + + /** Open the picker for choosing which of the user's groups to add the recipient to. */ + data class AddToAGroup(val recipientId: RecipientId, val groupMembership: List) : ConversationSettingsAction { + override fun toString(): String = "AddToAGroup($recipientId, groupMembershipCount=${groupMembership.size})" + } + + /** Open the conversation for one of the groups in common. */ + data class OpenGroupConversation(val recipient: Recipient) : ConversationSettingsAction { + override fun toString(): String = "OpenGroupConversation(${recipient.id})" + } + + /** Open the searchable list of group members. */ + data class NavigateToMemberSearch(val groupId: GroupId, val canAdd: Boolean, val hasGroupLink: Boolean) : ConversationSettingsAction + + /** Open the picker for choosing new members to add to the group. */ + data class AddMembersToGroup(val groupId: GroupId, val selectionLimits: SelectionLimits, val groupMembersWithoutSelf: List) : ConversationSettingsAction { + override fun toString(): String = "AddMembersToGroup($groupId, memberCount=${groupMembersWithoutSelf.size})" + } + + /** Tell the user the group is already at the maximum number of members. */ + data object ShowGroupHardLimitDialog : ConversationSettingsAction + + /** Open the sheet of actions for the group member the user tapped. */ + data class ShowRecipientBottomSheet(val recipientId: RecipientId, val groupId: GroupId) : ConversationSettingsAction + + /** Open the screen for editing the user's label in this group. */ + data class NavigateToMemberLabel(val groupId: GroupId) : ConversationSettingsAction + + /** Tell the user they aren't allowed to set a member label in this group. */ + data object ShowMemberLabelPermissionError : ConversationSettingsAction + + /** Open the group link screen. */ + data class NavigateToShareableGroupLink(val groupId: GroupId) : ConversationSettingsAction + + /** Open the list of pending requests and invites. */ + data class OpenRequestsAndInvites(val groupId: GroupId.V2) : ConversationSettingsAction + + /** Open the group permissions screen. */ + data class NavigateToPermissions(val groupId: GroupId) : ConversationSettingsAction + + /** Ask the user to confirm leaving the group. */ + data class ShowLeaveGroupDialog(val groupId: GroupId) : ConversationSettingsAction + + /** Ask the user to confirm ending the group for everyone. */ + data class ShowEndGroupDialog(val groupId: GroupId.V2, val groupTitle: String) : ConversationSettingsAction { + override fun toString(): String = "ShowEndGroupDialog($groupId)" + } + + /** Ask the user to confirm blocking the recipient. */ + data class ShowBlockDialog(val recipient: Recipient) : ConversationSettingsAction { + override fun toString(): String = "ShowBlockDialog(${recipient.id})" + } + + /** Ask the user to confirm unblocking the recipient. */ + data class ShowUnblockDialog(val recipient: Recipient) : ConversationSettingsAction { + override fun toString(): String = "ShowUnblockDialog(${recipient.id})" + } + + /** Ask the user to confirm reporting spam, optionally offering to block at the same time. */ + data class ShowReportSpamDialog(val recipient: Recipient, val canBlock: Boolean) : ConversationSettingsAction { + override fun toString(): String = "ShowReportSpamDialog(${recipient.id}, canBlock=$canBlock)" + } + + /** Tell the user why the block failed. */ + data class ShowBlockError(val failureReason: GroupChangeFailureReason) : ConversationSettingsAction + + /** Confirm to the user that the spam was reported. */ + data object ShowSpamReported : ConversationSettingsAction + + /** Confirm to the user that the spam was reported and the recipient blocked. */ + data object ShowSpamReportedAndBlocked : ConversationSettingsAction + + /** Tell the user why adding members failed. */ + data class ShowAddMembersError(val failureReason: GroupChangeFailureReason) : ConversationSettingsAction + + /** Tell the user which recipients were invited rather than added outright. */ + data class ShowGroupInvitesSentDialog(val invitesSentTo: List) : ConversationSettingsAction { + override fun toString(): String = "ShowGroupInvitesSentDialog(inviteCount=${invitesSentTo.size})" + } + + /** Confirm to the user how many members were added. */ + data class ShowMembersAdded(val membersAddedCount: Int) : ConversationSettingsAction + + /** Pop back to the conversation list, since this chat no longer exists. */ + data object GoToConversationList : ConversationSettingsAction +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsActivity.kt index de44460525..f75aa4bb40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsActivity.kt @@ -9,7 +9,6 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.DynamicConversationSettingsTheme import org.thoughtcrime.securesms.util.DynamicTheme @@ -35,7 +34,7 @@ open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSet companion object { @JvmStatic fun forGroup(context: Context, groupId: GroupId): Intent { - val startBundle = ConversationSettingsFragmentArgs.Builder(null, groupId, null) + val startBundle = ConversationSettingsFragmentArgs.Builder(null, groupId, null, ConversationSettingsKind.GROUP) .build() .toBundle() @@ -44,8 +43,8 @@ open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSet } @JvmStatic - fun forRecipient(context: Context, recipientId: RecipientId): Intent { - val startBundle = ConversationSettingsFragmentArgs.Builder(recipientId, null, null) + fun forRecipient(context: Context, recipient: Recipient): Intent { + val startBundle = ConversationSettingsFragmentArgs.Builder(recipient.id, null, null, ConversationSettingsKind.from(recipient)) .build() .toBundle() @@ -55,10 +54,12 @@ open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSet @JvmStatic fun forCall(context: Context, callPeer: Recipient, callMessageIds: LongArray): Intent { + val kind = ConversationSettingsKind.from(callPeer) + val startBundleBuilder = if (callPeer.isGroup) { - ConversationSettingsFragmentArgs.Builder(null, callPeer.requireGroupId(), callMessageIds) + ConversationSettingsFragmentArgs.Builder(null, callPeer.requireGroupId(), callMessageIds, kind) } else { - ConversationSettingsFragmentArgs.Builder(callPeer.id, null, callMessageIds) + ConversationSettingsFragmentArgs.Builder(callPeer.id, null, callMessageIds, kind) } val startBundle = startBundleBuilder.build().toBundle() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsEvent.kt deleted file mode 100644 index 113f3d7a67..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsEvent.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.conversation - -import org.thoughtcrime.securesms.groups.GroupId -import org.thoughtcrime.securesms.groups.SelectionLimits -import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason -import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.recipients.RecipientId - -sealed class ConversationSettingsEvent { - class AddToAGroup( - val recipientId: RecipientId, - val groupMembership: List - ) : ConversationSettingsEvent() - - class AddMembersToGroup( - val groupId: GroupId, - val selectionLimits: SelectionLimits, - val groupMembersWithoutSelf: List - ) : ConversationSettingsEvent() - - object ShowGroupHardLimitDialog : ConversationSettingsEvent() - - class ShowAddMembersToGroupError( - val failureReason: GroupChangeFailureReason - ) : ConversationSettingsEvent() - - class ShowBlockGroupError( - val failureReason: GroupChangeFailureReason - ) : ConversationSettingsEvent() - - class ShowGroupInvitesSentDialog( - val invitesSentTo: List - ) : ConversationSettingsEvent() - - class ShowMembersAdded( - val membersAddedCount: Int - ) : ConversationSettingsEvent() -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 2edd372628..ea9445a206 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -1,42 +1,33 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.components.settings.conversation import android.app.ActivityOptions import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter -import android.graphics.Rect import android.os.Bundle -import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.TextView import android.widget.Toast import androidx.activity.result.ActivityResultLauncher -import androidx.appcompat.widget.Toolbar -import androidx.core.content.ContextCompat +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.core.view.doOnPreDraw import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.Navigation import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -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 kotlinx.coroutines.flow.Flow +import org.signal.core.ui.compose.ComposeFragment import org.signal.core.ui.permissions.Permissions -import org.signal.core.util.DimensionUnit -import org.signal.core.util.Result -import org.signal.core.util.concurrent.LifecycleDisposable -import org.signal.core.util.concurrent.addTo import org.signal.core.util.getParcelableArrayListExtraCompat -import org.signal.core.util.orNull -import org.signal.core.util.requireDrawable +import org.signal.core.util.logging.Log import org.signal.core.util.requireParcelableCompat import org.signal.donations.InAppPaymentType import org.thoughtcrime.securesms.AvatarPreviewActivity @@ -44,44 +35,24 @@ import org.thoughtcrime.securesms.BlockUnblockDialog import org.thoughtcrime.securesms.MainActivity import org.thoughtcrime.securesms.PushContactSelectionActivity import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.badges.BadgeImageView -import org.thoughtcrime.securesms.badges.Badges -import org.thoughtcrime.securesms.badges.Badges.displayBadges -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 -import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon -import org.thoughtcrime.securesms.components.settings.DSLSettingsText -import org.thoughtcrime.securesms.components.settings.NO_TINT import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity -import org.thoughtcrime.securesms.components.settings.configure -import org.thoughtcrime.securesms.components.settings.conversation.preferences.AvatarPreference -import org.thoughtcrime.securesms.components.settings.conversation.preferences.BioTextPreference -import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference -import org.thoughtcrime.securesms.components.settings.conversation.preferences.CallPreference -import org.thoughtcrime.securesms.components.settings.conversation.preferences.GroupDescriptionPreference -import org.thoughtcrime.securesms.components.settings.conversation.preferences.InternalPreference -import org.thoughtcrime.securesms.components.settings.conversation.preferences.LargeIconClickPreference -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.Utils.formatMutedUntil +import org.thoughtcrime.securesms.components.settings.conversation.group.GroupSettingsEvent +import org.thoughtcrime.securesms.components.settings.conversation.group.GroupSettingsScreen +import org.thoughtcrime.securesms.components.settings.conversation.group.GroupSettingsViewModel +import org.thoughtcrime.securesms.components.settings.conversation.individual.IndividualSettingsEvent +import org.thoughtcrime.securesms.components.settings.conversation.individual.IndividualSettingsScreen +import org.thoughtcrime.securesms.components.settings.conversation.individual.IndividualSettingsViewModel +import org.thoughtcrime.securesms.components.settings.conversation.individual.NoteToSelfSettingsScreen +import org.thoughtcrime.securesms.components.settings.conversation.individual.ReleaseNotesSettingsScreen import org.thoughtcrime.securesms.conversation.ConversationIntents -import org.thoughtcrime.securesms.conversation.colors.ColorizerV2 -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 import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog import org.thoughtcrime.securesms.groups.ui.MemberSearchFragment import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersActivity @@ -91,38 +62,27 @@ import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescription import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob -import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter import org.thoughtcrime.securesms.main.MainNavigationDetailLocation import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory -import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository import org.thoughtcrime.securesms.nicknames.NicknameActivity import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity -import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientExporter import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.ui.about.AboutSheet import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment import org.thoughtcrime.securesms.starred.StarredMessagesActivity -import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.stories.StoryViewerArgs import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs import org.thoughtcrime.securesms.stories.viewer.AddToGroupStoryDelegate import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity import org.thoughtcrime.securesms.util.CommunicationActions -import org.thoughtcrime.securesms.util.DateUtils -import org.thoughtcrime.securesms.util.ExpirationUtil -import org.thoughtcrime.securesms.util.Material3OnScrollHelper -import org.thoughtcrime.securesms.util.RemoteConfig -import org.thoughtcrime.securesms.util.ViewUtil -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.navigation.safeNavigate -import org.thoughtcrime.securesms.util.views.SimpleProgressDialog import org.thoughtcrime.securesms.verify.VerifyIdentityActivity import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity -import java.util.Locale -import org.signal.core.ui.R as CoreUiR + +private val TAG = Log.tag(ConversationSettingsFragment::class) private const val REQUEST_CODE_VIEW_CONTACT = 1 private const val REQUEST_CODE_ADD_CONTACT = 2 @@ -130,66 +90,41 @@ private const val REQUEST_CODE_ADD_MEMBERS_TO_GROUP = 3 private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4 /** - * Settings screen for a conversation. + * Entry point for conversation settings. + * + * Hands off to the screen matching the conversation type, and carries out the [ConversationSettingsAction]s that need an + * Activity, FragmentManager, or the legacy nav graph. * * Hosts that want shared element enter transitions should implement [TransitionCallback]. */ -class ConversationSettingsFragment : - DSLSettingsFragment( - layoutId = R.layout.conversation_settings_fragment, - menuId = R.menu.conversation_settings - ) { - - override val listScrollsBehindToolbar: Boolean = true +class ConversationSettingsFragment : ComposeFragment() { private val args: ConversationSettingsFragmentArgs by navArgs() - private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) } - private val alertDisabledTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary_50) } - private val colorizer = ColorizerV2() - private val blockIcon by lazy { - requireContext().requireDrawable(R.drawable.symbol_block_24).apply { - colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN) - } - } - private val leaveIcon by lazy { - requireContext().requireDrawable(R.drawable.symbol_leave_24).apply { - colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN) - } - } + private val callMessageIds: LongArray get() = args.callMessageIds ?: longArrayOf() - private val endGroupIcon by lazy { - requireContext().requireDrawable(R.drawable.symbol_x_circle_24).apply { - colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN) - } - } + private val repository: ConversationSettingsRepository by lazy { ConversationSettingsRepository(requireContext()) } - private val viewModel by viewModels( - factoryProducer = { - ConversationSettingsViewModel.Factory( - recipientId = args.recipientId, - groupId = args.groupId, - callMessageIds = args.callMessageIds ?: longArrayOf(), - repository = ConversationSettingsRepository(requireContext()), - messageRequestRepository = MessageRequestRepository(requireContext()) - ) - } + // These are lazy, only one gets built + private val individualViewModel: IndividualSettingsViewModel by viewModels( + factoryProducer = { IndividualSettingsViewModel.Factory(requireNotNull(args.recipientId), args.kind, callMessageIds, repository) } + ) + + private val groupViewModel: GroupSettingsViewModel by viewModels( + factoryProducer = { GroupSettingsViewModel.Factory(requireNotNull(args.groupId), callMessageIds, repository) } ) private var transitionCallback: TransitionCallback? = null private var chatRouter: MainNavigationChatDetailRouter? = null - private lateinit var toolbar: Toolbar - private lateinit var toolbarAvatarContainer: FrameLayout - private lateinit var toolbarAvatar: AvatarImageView - private lateinit var toolbarBadge: BadgeImageView - private lateinit var toolbarTitle: TextView - private lateinit var toolbarBackground: View + /** The avatar and shared media views own the shared element transitions out of this screen. */ + private var avatarView: View? = null + private var lastClickedSharedMediaView: View? = null + private lateinit var addToGroupStoryDelegate: AddToGroupStoryDelegate private lateinit var nicknameLauncher: ActivityResultLauncher private val navController get() = Navigation.findNavController(requireView()) - private val lifecycleDisposable = LifecycleDisposable() override fun onAttach(context: Context) { super.onAttach(context) @@ -197,21 +132,19 @@ class ConversationSettingsFragment : chatRouter = context as? MainNavigationChatDetailRouter } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - toolbar = view.findViewById(R.id.toolbar) - toolbarAvatarContainer = view.findViewById(R.id.toolbar_avatar_container) - toolbarAvatar = view.findViewById(R.id.toolbar_avatar) - toolbarBadge = view.findViewById(R.id.toolbar_badge) - toolbarTitle = view.findViewById(R.id.toolbar_title) - toolbarBackground = view.findViewById(R.id.toolbar_background) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - val args: ConversationSettingsFragmentArgs = ConversationSettingsFragmentArgs.fromBundle(requireArguments()) - if (args.recipientId != null) { - layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges + nicknameLauncher = registerForActivityResult(NicknameActivity.Contract()) { + // No result to handle -- the nickname is saved to the database, and the recipient observer picks it up } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + addToGroupStoryDelegate = AddToGroupStoryDelegate(this) + parentFragmentManager.setFragmentResultListener(MemberLabelEducationSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle -> val groupId = bundle.requireParcelableCompat(MemberLabelEducationSheet.KEY_GROUP_ID, GroupId.V2::class.java) navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupId)) @@ -223,28 +156,141 @@ class ConversationSettingsFragment : } parentFragmentManager.setFragmentResultListener(MemberSearchFragment.RESULT_ADD_MEMBERS, viewLifecycleOwner) { _, _ -> - viewModel.onAddToGroup() + groupViewModel.onEvent(GroupSettingsEvent.AddMembersClicked) } - - recyclerView?.addOnScrollListener(ConversationSettingsOnUserScrolledAnimationHelper(toolbarAvatarContainer, toolbarTitle, toolbarBackground)) } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - when (requestCode) { - REQUEST_CODE_ADD_MEMBERS_TO_GROUP -> if (data != null) { - val selected: List = requireNotNull(data.getParcelableArrayListExtraCompat(PushContactSelectionActivity.KEY_SELECTED_RECIPIENTS, RecipientId::class.java)) - val progress: SimpleProgressDialog.DismissibleDialog = SimpleProgressDialog.showDelayed(requireContext()) + override fun onDestroyView() { + super.onDestroyView() + avatarView = null + lastClickedSharedMediaView = null + } - viewModel.onAddToGroupComplete(selected) { - progress.dismiss() + /** + * Runs whichever of these applies to the conversation we're showing. Used to forward the things that happen to the + * fragment -- activity results, dialog confirmations -- into the live view model's own events. + */ + private fun dispatch( + individual: (IndividualSettingsViewModel) -> Unit = {}, + group: (GroupSettingsViewModel) -> Unit = {} + ) { + if (args.kind == ConversationSettingsKind.GROUP) { + group(groupViewModel) + } else { + individual(individualViewModel) + } + } + + @Composable + override fun FragmentContent() { + when (args.kind) { + ConversationSettingsKind.INDIVIDUAL -> IndividualContent() + ConversationSettingsKind.NOTE_TO_SELF -> NoteToSelfContent() + ConversationSettingsKind.RELEASE_NOTES -> ReleaseNotesContent() + ConversationSettingsKind.GROUP -> GroupContent() + } + } + + @Composable + private fun IndividualContent() { + val state by individualViewModel.state.collectAsStateWithLifecycle() + + CollectActions(individualViewModel.actions) + NotifyWhenLoaded(state.isLoaded) + + IndividualSettingsScreen( + state = state, + onEvent = individualViewModel::onEvent, + onNavigationClick = { requireActivity().onBackPressedDispatcher.onBackPressed() }, + onAvatarViewCreated = { avatarView = it }, + onSharedMediaViewClicked = { lastClickedSharedMediaView = it } + ) + } + + @Composable + private fun NoteToSelfContent() { + val state by individualViewModel.state.collectAsStateWithLifecycle() + + CollectActions(individualViewModel.actions) + NotifyWhenLoaded(state.isLoaded) + + NoteToSelfSettingsScreen( + state = state, + onEvent = individualViewModel::onEvent, + onNavigationClick = { requireActivity().onBackPressedDispatcher.onBackPressed() }, + onAvatarViewCreated = { avatarView = it }, + onSharedMediaViewClicked = { lastClickedSharedMediaView = it } + ) + } + + @Composable + private fun ReleaseNotesContent() { + val state by individualViewModel.state.collectAsStateWithLifecycle() + + CollectActions(individualViewModel.actions) + NotifyWhenLoaded(state.isLoaded) + + ReleaseNotesSettingsScreen( + state = state, + onEvent = individualViewModel::onEvent, + onNavigationClick = { requireActivity().onBackPressedDispatcher.onBackPressed() }, + onAvatarViewCreated = { avatarView = it }, + onSharedMediaViewClicked = { lastClickedSharedMediaView = it } + ) + } + + @Composable + private fun GroupContent() { + val state by groupViewModel.state.collectAsStateWithLifecycle() + + CollectActions(groupViewModel.actions) + NotifyWhenLoaded(state.isLoaded) + + GroupSettingsScreen( + state = state, + onEvent = groupViewModel::onEvent, + onNavigationClick = { requireActivity().onBackPressedDispatcher.onBackPressed() }, + onAvatarViewCreated = { avatarView = it }, + onSharedMediaViewClicked = { lastClickedSharedMediaView = it } + ) + } + + @Composable + private fun CollectActions(actions: Flow) { + LaunchedEffect(actions) { + actions.collect { action -> handleAction(action) } + } + } + + @Composable + private fun NotifyWhenLoaded(isLoaded: Boolean) { + LaunchedEffect(isLoaded) { + if (isLoaded) { + (view?.parent as? ViewGroup)?.doOnPreDraw { + transitionCallback?.onReadyForEnterTransition() } } + } + } - REQUEST_CODE_RETURN_FROM_MEDIA -> viewModel.refreshSharedMedia() - - REQUEST_CODE_ADD_CONTACT -> viewModel.refreshRecipient() - - REQUEST_CODE_VIEW_CONTACT -> viewModel.refreshRecipient() + @Suppress("DEPRECATION") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + when (requestCode) { + REQUEST_CODE_ADD_MEMBERS_TO_GROUP -> { + if (data != null) { + val selected: List = requireNotNull(data.getParcelableArrayListExtraCompat(PushContactSelectionActivity.KEY_SELECTED_RECIPIENTS, RecipientId::class.java)) + groupViewModel.onEvent(GroupSettingsEvent.AddMembersSelected(selected)) + } + } + REQUEST_CODE_RETURN_FROM_MEDIA -> { + dispatch( + individual = { it.onEvent(IndividualSettingsEvent.SharedMediaRefreshRequested) }, + group = { it.onEvent(GroupSettingsEvent.SharedMediaRefreshRequested) } + ) + } + REQUEST_CODE_ADD_CONTACT, REQUEST_CODE_VIEW_CONTACT -> { + individualViewModel.onEvent(IndividualSettingsEvent.RecipientRefreshRequested) + } } } @@ -252,13 +298,269 @@ class ConversationSettingsFragment : Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return if (item.itemId == R.id.action_edit) { - val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments()) - startActivity(CreateProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(args.groupId))) - true - } else { - super.onOptionsItemSelected(item) + @Suppress("DEPRECATION") + private fun handleAction(action: ConversationSettingsAction) { + Log.d(TAG, "[Action] $action") + + when (action) { + is ConversationSettingsAction.ShowAvatarPreview -> { + val intent = AvatarPreviewActivity.intentFromRecipientId(requireContext(), action.recipientId) + val transitionBundle = avatarView?.let { AvatarPreviewActivity.createTransitionBundle(requireActivity(), it) } + startActivity(intent, transitionBundle) + } + is ConversationSettingsAction.ShowStoryOrAvatarDialog -> { + val viewAvatarIntent = AvatarPreviewActivity.intentFromRecipientId(requireContext(), action.recipientId) + val transitionBundle = avatarView?.let { AvatarPreviewActivity.createTransitionBundle(requireActivity(), it) } + val viewStoryIntent = StoryViewerActivity.createIntent( + requireContext(), + StoryViewerArgs( + recipientId = action.recipientId, + isInHiddenStoryMode = action.isInHiddenStoryMode, + isFromQuote = true + ) + ) + + StoryDialogs.displayStoryOrProfileImage( + context = requireContext(), + onViewStory = { startActivity(viewStoryIntent) }, + onViewAvatar = { startActivity(viewAvatarIntent, transitionBundle) } + ) + } + is ConversationSettingsAction.ShowBadgeSheet -> { + ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, action.recipientId, action.badge) + } + is ConversationSettingsAction.ShowAboutSheet -> { + AboutSheet.create(action.recipient).show(parentFragmentManager, null) + } + is ConversationSettingsAction.EditGroupProfile -> { + startActivity(CreateProfileActivity.getIntentForGroupProfile(requireActivity(), action.groupId)) + } + is ConversationSettingsAction.EditGroupDescription -> { + startActivity(CreateProfileActivity.getIntentForGroupProfileWithFocusedDescription(requireActivity(), action.groupId)) + } + is ConversationSettingsAction.ShowGroupDescriptionDialog -> { + GroupDescriptionDialog.show(childFragmentManager, action.groupId, null, action.shouldLinkify) + } + ConversationSettingsAction.ShowGroupsLearnMore -> { + GroupsLearnMoreBottomSheetDialogFragment.show(parentFragmentManager) + } + ConversationSettingsAction.ShowInviteFriends -> { + startActivity(AppSettingsActivity.invite(requireContext())) + } + is ConversationSettingsAction.NavigateToInternalDetails -> { + navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToInternalDetailsSettingsFragment(action.recipientId)) + } + is ConversationSettingsAction.OpenConversation -> { + val builder = ConversationIntents.createBuilderSync(requireContext(), action.recipientId, action.threadId) + startActivity(builder.withSearchOpen(action.withSearchOpen).build()) + + if (action.withSearchOpen && requireActivity() !is MainNavigationChatDetailRouter) { + requireActivity().finish() + } + } + is ConversationSettingsAction.AddToGroupStory -> { + addToGroupStoryDelegate.addToStory(action.recipientId) + } + is ConversationSettingsAction.StartVideoCall -> { + CommunicationActions.startVideoCall(requireActivity(), action.recipient) { + YouAreAlreadyInACallSnackbar.show(requireView()) + } + } + is ConversationSettingsAction.StartAudioCall -> { + CommunicationActions.startVoiceCall(requireActivity(), action.recipient) { + YouAreAlreadyInACallSnackbar.show(requireView()) + } + } + ConversationSettingsAction.ShowMuteUntilTimePicker -> { + childFragmentManager.setFragmentResultListener(MuteUntilTimePickerBottomSheet.REQUEST_KEY, viewLifecycleOwner) { _, bundle -> + val muteUntil = bundle.getLong(MuteUntilTimePickerBottomSheet.RESULT_TIMESTAMP) + dispatch( + individual = { it.onEvent(IndividualSettingsEvent.MuteDurationSelected(muteUntil)) }, + group = { it.onEvent(GroupSettingsEvent.MuteDurationSelected(muteUntil)) } + ) + } + MuteUntilTimePickerBottomSheet.show(childFragmentManager) + } + is ConversationSettingsAction.NavigateToDisappearingMessages -> { + navController.safeNavigate( + ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToAppSettingsExpireTimer() + .setInitialValue(action.initialValue) + .setRecipientId(action.recipientId) + .setForResultMode(false) + ) + } + is ConversationSettingsAction.EditNickname -> { + nicknameLauncher.launch(NicknameActivity.Args(action.recipientId, false)) + } + is ConversationSettingsAction.OpenChatWallpaper -> { + startActivity(ChatWallpaperActivity.createIntent(requireContext(), action.recipientId)) + } + is ConversationSettingsAction.NavigateToSoundsAndNotifications -> { + val directions = if (action.useInternalScreen) { + ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment2(action.recipientId) + } else { + ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(action.recipientId) + } + + navController.safeNavigate(directions) + } + is ConversationSettingsAction.OpenStarredMessages -> { + startActivity(StarredMessagesActivity.createIntent(requireContext(), action.threadId)) + } + is ConversationSettingsAction.ViewContact -> { + startActivityForResult(Intent(Intent.ACTION_VIEW, action.recipient.contactUri), REQUEST_CODE_VIEW_CONTACT) + } + is ConversationSettingsAction.AddContact -> { + try { + startActivityForResult(RecipientExporter.export(action.recipient).asAddContactIntent(), REQUEST_CODE_ADD_CONTACT) + } catch (e: ActivityNotFoundException) { + Toast.makeText(requireContext(), R.string.ConversationSettingsFragment__contacts_app_not_found, Toast.LENGTH_SHORT).show() + } + } + is ConversationSettingsAction.ShowSafetyNumber -> { + VerifyIdentityActivity.startOrShowExchangeMessagesDialog(requireActivity(), action.identityRecord) + } + is ConversationSettingsAction.ShowMediaPreview -> { + val view = lastClickedSharedMediaView + if (view != null) { + view.transitionName = "thumb" + val options = ActivityOptions.makeSceneTransitionAnimation(requireActivity(), view, "thumb") + startActivityForResult( + MediaIntentFactory.intentFromMediaRecord(requireContext(), action.mediaRecord, action.isLtr, allMediaInRail = true), + REQUEST_CODE_RETURN_FROM_MEDIA, + options.toBundle() + ) + } else { + startActivityForResult( + MediaIntentFactory.intentFromMediaRecord(requireContext(), action.mediaRecord, action.isLtr, allMediaInRail = true), + REQUEST_CODE_RETURN_FROM_MEDIA + ) + } + } + is ConversationSettingsAction.DownloadMedia -> { + action.mediaRecord.attachment?.let { AttachmentDownloadJob.downloadAttachmentIfNeeded(it) } + } + ConversationSettingsAction.ShowMediaNotSentYet -> { + Toast.makeText(requireContext(), R.string.ConversationSettingsFragment__this_media_is_not_sent_yet, Toast.LENGTH_LONG).show() + } + is ConversationSettingsAction.ShowMediaOverview -> { + startActivityForResult(MediaOverviewActivity.forThread(requireContext(), action.threadId), REQUEST_CODE_RETURN_FROM_MEDIA) + } + ConversationSettingsAction.OpenSupportCenter -> { + CommunicationActions.openBrowserLink(requireContext(), getString(R.string.support_center_url)) + } + ConversationSettingsAction.OpenContactUs -> { + startActivity(AppSettingsActivity.help(requireContext())) + } + ConversationSettingsAction.OpenDonate -> { + startActivity(CheckoutFlowActivity.createIntent(requireContext(), InAppPaymentType.ONE_TIME_DONATION)) + } + is ConversationSettingsAction.AddToAGroup -> { + startActivity(AddToGroupsActivity.createIntent(requireContext(), action.recipientId, action.groupMembership)) + } + is ConversationSettingsAction.OpenGroupConversation -> { + CommunicationActions.startConversation(requireActivity(), action.recipient, null) + if (requireActivity() !is MainNavigationChatDetailRouter) { + requireActivity().finish() + } + } + is ConversationSettingsAction.NavigateToMemberSearch -> { + navController.safeNavigate( + ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberSearchFragment(action.groupId, action.canAdd, action.hasGroupLink) + ) + } + is ConversationSettingsAction.AddMembersToGroup -> { + startActivityForResult(AddMembersActivity.createIntent(requireContext(), action), REQUEST_CODE_ADD_MEMBERS_TO_GROUP) + } + ConversationSettingsAction.ShowGroupHardLimitDialog -> { + GroupLimitDialog.showHardLimitMessage(requireContext()) + } + is ConversationSettingsAction.ShowRecipientBottomSheet -> { + RecipientBottomSheetDialogFragment.show(parentFragmentManager, action.recipientId, action.groupId) + } + is ConversationSettingsAction.NavigateToMemberLabel -> { + navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(action.groupId)) + } + ConversationSettingsAction.ShowMemberLabelPermissionError -> { + Snackbar.make(requireView(), R.string.GroupMemberLabel__error_no_edit_permission, Snackbar.LENGTH_SHORT).show() + } + is ConversationSettingsAction.NavigateToShareableGroupLink -> { + navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToShareableGroupLinkFragment(action.groupId)) + } + is ConversationSettingsAction.OpenRequestsAndInvites -> { + startActivity(ManagePendingAndRequestingMembersActivity.newIntent(requireContext(), action.groupId)) + } + is ConversationSettingsAction.NavigateToPermissions -> { + navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToPermissionsSettingsFragment(action.groupId)) + } + is ConversationSettingsAction.ShowLeaveGroupDialog -> { + LeaveGroupDialog.handleLeavePushGroup(requireActivity(), action.groupId.requirePush(), null) + } + is ConversationSettingsAction.ShowEndGroupDialog -> { + EndGroupDialog.show(requireActivity(), action.groupId, action.groupTitle) + } + is ConversationSettingsAction.ShowBlockDialog -> { + BlockUnblockDialog.showBlockFor(requireContext(), action.recipient) { + dispatch( + individual = { it.onEvent(IndividualSettingsEvent.BlockConfirmed) }, + group = { it.onEvent(GroupSettingsEvent.BlockConfirmed) } + ) + } + } + is ConversationSettingsAction.ShowUnblockDialog -> { + BlockUnblockDialog.showUnblockFor(requireContext(), action.recipient) { + dispatch( + individual = { it.onEvent(IndividualSettingsEvent.UnblockConfirmed) }, + group = { it.onEvent(GroupSettingsEvent.UnblockConfirmed) } + ) + } + } + is ConversationSettingsAction.ShowReportSpamDialog -> { + BlockUnblockDialog.showReportSpamFor( + requireContext(), + action.recipient, + { + dispatch( + individual = { it.onEvent(IndividualSettingsEvent.ReportSpamConfirmed) }, + group = { it.onEvent(GroupSettingsEvent.ReportSpamConfirmed) } + ) + }, + if (action.canBlock) { + Runnable { + dispatch( + individual = { it.onEvent(IndividualSettingsEvent.BlockAndReportSpamConfirmed) }, + group = { it.onEvent(GroupSettingsEvent.BlockAndReportSpamConfirmed) } + ) + } + } else { + null + } + ) + } + is ConversationSettingsAction.ShowBlockError -> { + Toast.makeText(requireContext(), GroupErrors.getUserDisplayMessage(action.failureReason), Toast.LENGTH_LONG).show() + } + ConversationSettingsAction.ShowSpamReported -> { + Toast.makeText(requireContext(), R.string.ConversationFragment_reported_as_spam, Toast.LENGTH_SHORT).show() + } + ConversationSettingsAction.ShowSpamReportedAndBlocked -> { + Toast.makeText(requireContext(), R.string.ConversationFragment_reported_as_spam_and_blocked, Toast.LENGTH_SHORT).show() + } + is ConversationSettingsAction.ShowAddMembersError -> { + Toast.makeText(requireContext(), GroupErrors.getUserDisplayMessage(action.failureReason), Toast.LENGTH_LONG).show() + } + is ConversationSettingsAction.ShowGroupInvitesSentDialog -> { + if (action.invitesSentTo.isNotEmpty()) { + GroupInviteSentDialog.show(childFragmentManager, action.invitesSentTo) + } + } + is ConversationSettingsAction.ShowMembersAdded -> { + val message = resources.getQuantityString(R.plurals.ManageGroupActivity_added, action.membersAddedCount, action.membersAddedCount) + Snackbar.make(requireView(), message, Snackbar.LENGTH_SHORT).show() + } + ConversationSettingsAction.GoToConversationList -> { + goToConversationList() + } } } @@ -270,1016 +572,6 @@ class ConversationSettingsFragment : } } - override fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper { - return object : Material3OnScrollHelper( - activity = requireActivity(), - views = listOf(toolbar!!), - lifecycleOwner = viewLifecycleOwner - ) { - override val inactiveColorSet = ColorSet( - toolbarColorRes = CoreUiR.color.signal_colorBackground_0 - ) - } - } - - override fun bindAdapter(adapter: MappingAdapter) { - nicknameLauncher = registerForActivityResult(NicknameActivity.Contract()) { - // Intentionally left blank - } - - val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments()) - - BioTextPreference.register(adapter) - AvatarPreference.register(adapter) - ButtonStripPreference.register(adapter) - LargeIconClickPreference.register(adapter) - SharedMediaPreference.register(adapter) - RecipientPreference.register(adapter) - InternalPreference.register(adapter) - GroupDescriptionPreference.register(adapter) - LegacyGroupPreference.register(adapter) - CallPreference.register(adapter) - - val recipientId = args.recipientId - if (recipientId != null) { - Badge.register(adapter) { badge, _, _ -> - ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, recipientId, badge) - } - } - - addToGroupStoryDelegate = AddToGroupStoryDelegate(this) - viewModel.state.observe(viewLifecycleOwner) { state -> - - if (state.recipient != Recipient.UNKNOWN) { - toolbarAvatar.buildOptions() - .withQuickContactEnabled(false) - .withUseSelfProfileAvatar(false) - .withFixedSize(ViewUtil.dpToPx(80)) - .load(state.recipient) - - if (!state.recipient.isSelf) { - toolbarBadge.setBadgeFromRecipient(state.recipient) - } - - state.withRecipientSettingsState { - toolbarTitle.text = if (state.recipient.isSelf) getString(R.string.note_to_self) else state.recipient.getDisplayName(requireContext()) - } - - state.withGroupSettingsState { - toolbarTitle.text = it.groupTitle - toolbar.menu.findItem(R.id.action_edit).isVisible = it.canEditGroupAttributes - } - } - - adapter.submitList(getConfiguration(state).toMappingModelList()) { - if (state.isLoaded) { - (view?.parent as? ViewGroup)?.doOnPreDraw { - transitionCallback?.onReadyForEnterTransition() - } - } - } - } - - lifecycleDisposable.bindTo(viewLifecycleOwner) - lifecycleDisposable += viewModel.events.subscribe { event -> - when (event) { - is ConversationSettingsEvent.AddToAGroup -> handleAddToAGroup(event) - is ConversationSettingsEvent.AddMembersToGroup -> handleAddMembersToGroup(event) - ConversationSettingsEvent.ShowGroupHardLimitDialog -> showGroupHardLimitDialog() - is ConversationSettingsEvent.ShowAddMembersToGroupError -> showAddMembersToGroupError(event) - is ConversationSettingsEvent.ShowBlockGroupError -> showBlockGroupError(event) - is ConversationSettingsEvent.ShowGroupInvitesSentDialog -> showGroupInvitesSentDialog(event) - is ConversationSettingsEvent.ShowMembersAdded -> showMembersAdded(event) - } - } - } - - private fun getConfiguration(state: ConversationSettingsState): DSLConfiguration { - return configure { - if (state.recipient == Recipient.UNKNOWN) { - return@configure - } - - customPref( - AvatarPreference.Model( - recipient = state.recipient, - storyViewState = state.storyViewState, - onAvatarClick = { avatar -> - val viewAvatarIntent = AvatarPreviewActivity.intentFromRecipientId(requireContext(), state.recipient.id) - val viewAvatarTransitionBundle = AvatarPreviewActivity.createTransitionBundle(requireActivity(), avatar) - - if (Stories.isFeatureEnabled() && avatar.hasStory()) { - val viewStoryIntent = StoryViewerActivity.createIntent( - requireContext(), - StoryViewerArgs( - recipientId = state.recipient.id, - isInHiddenStoryMode = state.recipient.shouldHideStory, - isFromQuote = true - ) - ) - StoryDialogs.displayStoryOrProfileImage( - context = requireContext(), - onViewStory = { startActivity(viewStoryIntent) }, - onViewAvatar = { startActivity(viewAvatarIntent, viewAvatarTransitionBundle) } - ) - } else if (!state.recipient.isSelf) { - startActivity(viewAvatarIntent, viewAvatarTransitionBundle) - } - }, - onBadgeClick = { badge -> - ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, state.recipient.id, badge) - } - ) - ) - - state.withRecipientSettingsState { - customPref( - BioTextPreference.RecipientModel( - recipient = state.recipient, - onHeadlineClickListener = if (state.recipient.isSelf || !state.recipient.isIndividual) { - null - } else { - { AboutSheet.create(state.recipient).show(parentFragmentManager, null) } - } - ) - ) - } - - state.withGroupSettingsState { groupState -> - - val groupMembershipDescription = if (groupState.groupId.isV1) { - String.format("%s ยท %s", groupState.membershipCountDescription, getString(R.string.ManageGroupActivity_legacy_group)) - } else if (!groupState.canEditGroupAttributes && groupState.groupDescription.isNullOrEmpty()) { - groupState.membershipCountDescription - } else { - null - } - - customPref( - BioTextPreference.GroupModel( - groupTitle = groupState.groupTitle, - groupMembershipDescription = groupMembershipDescription, - isTerminated = groupState.isTerminated - ) - ) - - if (groupState.groupId.isV2 && !groupState.isTerminated) { - customPref( - GroupDescriptionPreference.Model( - groupId = groupState.groupId, - groupDescription = groupState.groupDescription, - descriptionShouldLinkify = groupState.groupDescriptionShouldLinkify, - canEditGroupAttributes = groupState.canEditGroupAttributes, - onEditGroupDescription = { - startActivity(CreateProfileActivity.getIntentForGroupProfileWithFocusedDescription(requireActivity(), groupState.groupId)) - }, - onViewGroupDescription = { - GroupDescriptionDialog.show(childFragmentManager, groupState.groupId, null, groupState.groupDescriptionShouldLinkify) - } - ) - ) - } else if (groupState.legacyGroupState != LegacyGroupPreference.State.NONE) { - customPref( - LegacyGroupPreference.Model( - state = groupState.legacyGroupState, - onLearnMoreClick = { GroupsLearnMoreBottomSheetDialogFragment.show(parentFragmentManager) }, - onMmsWarningClick = { startActivity(AppSettingsActivity.invite(requireContext())) } - ) - ) - } - } - - if (state.displayInternalRecipientDetails) { - customPref( - InternalPreference.Model( - recipient = state.recipient, - onInternalDetailsClicked = { - val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToInternalDetailsSettingsFragment(state.recipient.id) - navController.safeNavigate(action) - } - ) - ) - } - - customPref( - ButtonStripPreference.Model( - state = state.buttonStripState, - enabled = !state.isDeprecatedOrUnregistered, - onMessageClick = { - val intent = ConversationIntents - .createBuilderSync(requireContext(), state.recipient.id, state.threadId) - .build() - - startActivity(intent) - }, - onAddToStoryClick = { - if (state.recipient.isPushV2Group && state.requireGroupSettingsState().isAnnouncementGroup && !state.requireGroupSettingsState().isSelfAdmin) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.ConversationSettingsFragment__cant_add_to_group_story) - .setMessage(R.string.ConversationSettingsFragment__only_admins_of_this_group_can_add_to_its_story) - .setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() } - .show() - } else { - addToGroupStoryDelegate.addToStory(state.recipient.id) - } - }, - onVideoClick = { - if (state.recipient.isPushV2Group && state.requireGroupSettingsState().isAnnouncementGroup && !state.requireGroupSettingsState().isSelfAdmin) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.ConversationActivity_cant_start_group_call) - .setMessage(R.string.ConversationActivity_only_admins_of_this_group_can_start_a_call) - .setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() } - .show() - } else { - CommunicationActions.startVideoCall(requireActivity(), state.recipient) { - YouAreAlreadyInACallSnackbar.show(requireView()) - } - } - }, - onAudioClick = { - CommunicationActions.startVoiceCall(requireActivity(), state.recipient) { - YouAreAlreadyInACallSnackbar.show(requireView()) - } - }, - onMuteClick = { view -> - if (!state.buttonStripState.isMuted) { - MuteContextMenu.show(view, requireView() as ViewGroup, childFragmentManager, viewLifecycleOwner) { duration -> - viewModel.setMuteUntil(duration) - } - } else { - MaterialAlertDialogBuilder(requireContext()) - .setMessage(state.recipient.muteUntil.formatMutedUntil(requireContext())) - .setPositiveButton(R.string.ConversationSettingsFragment__unmute) { dialog, _ -> - viewModel.unmute() - dialog.dismiss() - } - .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } - .show() - } - }, - onSearchClick = { - lifecycleDisposable += ConversationIntents.createBuilder(requireContext(), state.recipient.id, state.threadId) - .subscribeBy { builder -> - val intent = builder - .withSearchOpen(true) - .build() - - startActivity(intent) - if (requireActivity() !is MainNavigationChatDetailRouter) { - requireActivity().finish() - } - } - } - ) - ) - - dividerPref() - - if (state.calls.isNotEmpty()) { - val firstCall = state.calls.first() - sectionHeaderPref(DSLSettingsText.from(DateUtils.formatDate(Locale.getDefault(), firstCall.record.timestamp))) - - for (call in state.calls) { - customPref(call) - } - - dividerPref() - } - - if (state.recipient.isReleaseNotes) { - textPref( - icon = DSLSettingsIcon.from(R.drawable.symbol_official_20), - title = DSLSettingsText.from(R.string.ReleaseNotes__this_is_official_chat) - ) - textPref( - icon = DSLSettingsIcon.from(R.drawable.symbol_bell_20), - title = DSLSettingsText.from(R.string.ReleaseNotes__keep_up_to_date) - ) - dividerPref() - } - - val summary = DSLSettingsText.from(formatDisappearingMessagesLifespan(state.disappearingMessagesLifespan)) - val icon = if (state.disappearingMessagesLifespan <= 0 || state.recipient.isBlocked) { - R.drawable.symbol_timer_slash_24 - } else { - R.drawable.symbol_timer_24 - } - - var enabled = !state.recipient.isBlocked - state.withGroupSettingsState { - enabled = it.canEditGroupAttributes && !state.recipient.isBlocked - } - - if (!state.recipient.isReleaseNotes && !state.recipient.isBlocked) { - clickPref( - title = DSLSettingsText.from(R.string.ConversationSettingsFragment__disappearing_messages), - summary = summary, - icon = DSLSettingsIcon.from(icon), - isEnabled = enabled && !state.isDeprecatedOrUnregistered, - onClick = { - val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToAppSettingsExpireTimer() - .setInitialValue(state.disappearingMessagesLifespan) - .setRecipientId(state.recipient.id) - .setForResultMode(false) - - navController.safeNavigate(action) - } - ) - } - - if (state.recipient.isIndividual && !state.recipient.isSelf) { - clickPref( - title = DSLSettingsText.from(R.string.NicknameActivity__nickname), - icon = DSLSettingsIcon.from(CoreUiR.drawable.symbol_edit_24), - onClick = { - nicknameLauncher.launch( - NicknameActivity.Args( - state.recipient.id, - false - ) - ) - } - ) - } - - if (!state.recipient.isReleaseNotes) { - clickPref( - title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper), - icon = DSLSettingsIcon.from(R.drawable.symbol_color_24), - onClick = { - startActivity(ChatWallpaperActivity.createIntent(requireContext(), state.recipient.id)) - } - ) - } - - if (!state.recipient.isSelf) { - clickPref( - title = if (RemoteConfig.internalUser) { - DSLSettingsText.from("${getString(R.string.ConversationSettingsFragment__sounds_and_notifications)} (Internal Only)") - } else { - DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications) - }, - icon = DSLSettingsIcon.from(R.drawable.symbol_speaker_24), - isEnabled = !state.isDeprecatedOrUnregistered, - onClick = { - val action = if (RemoteConfig.internalUser) { - ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment2(state.recipient.id) - } else { - ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id) - } - - navController.safeNavigate(action) - } - ) - } - - if (!state.recipient.isReleaseNotes && SignalStore.labs.starredMessages) { - clickPref( - title = DSLSettingsText.from(R.string.ConversationSettingsFragment__starred_messages), - icon = DSLSettingsIcon.from(R.drawable.symbol_star_outline_24), - onClick = { - startActivity(StarredMessagesActivity.createIntent(requireContext(), state.threadId)) - } - ) - } - - state.withRecipientSettingsState { recipientState -> - when (recipientState.contactLinkState) { - ContactLinkState.OPEN -> { - @Suppress("DEPRECATION") - clickPref( - title = DSLSettingsText.from(R.string.ConversationSettingsFragment__contact_details), - icon = DSLSettingsIcon.from(R.drawable.ic_profile_circle_24), - onClick = { - startActivityForResult(Intent(Intent.ACTION_VIEW, state.recipient.contactUri), REQUEST_CODE_VIEW_CONTACT) - } - ) - } - - ContactLinkState.ADD -> { - @Suppress("DEPRECATION") - clickPref( - title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_as_a_contact), - icon = DSLSettingsIcon.from(R.drawable.ic_plus_24), - onClick = { - try { - startActivityForResult(RecipientExporter.export(state.recipient).asAddContactIntent(), REQUEST_CODE_ADD_CONTACT) - } catch (e: ActivityNotFoundException) { - Toast.makeText(context, R.string.ConversationSettingsFragment__contacts_app_not_found, Toast.LENGTH_SHORT).show() - } - } - ) - } - - ContactLinkState.NONE -> { - } - } - - if (!state.recipient.isReleaseNotes && !state.recipient.isSelf) { - clickPref( - title = DSLSettingsText.from(R.string.ConversationSettingsFragment__view_safety_number), - icon = DSLSettingsIcon.from(R.drawable.symbol_safety_number_24), - isEnabled = !state.isDeprecatedOrUnregistered, - onClick = { - VerifyIdentityActivity.startOrShowExchangeMessagesDialog(requireActivity(), recipientState.identityRecord) - } - ) - } - } - - if (state.sharedMedia.isNotEmpty()) { - dividerPref() - - sectionHeaderPref(R.string.recipient_preference_activity__shared_media) - - @Suppress("DEPRECATION") - customPref( - SharedMediaPreference.Model( - mediaRecords = state.sharedMedia, - mediaIds = state.sharedMediaIds, - onMediaRecordClick = { view, mediaRecord, isLtr -> - val attachment = mediaRecord.attachment - if (attachment == null) { - Toast.makeText(context, R.string.ConversationSettingsFragment__this_media_is_not_sent_yet, Toast.LENGTH_LONG).show() - return@Model - } - if (attachment.displayUri == null) { - if (attachment.transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED) { - AttachmentDownloadJob.downloadAttachmentIfNeeded(attachment) - } else { - Toast.makeText(context, R.string.ConversationSettingsFragment__this_media_is_not_sent_yet, Toast.LENGTH_LONG).show() - } - return@Model - } - if (attachment.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && - attachment.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED - ) { - Toast.makeText(context, R.string.ConversationSettingsFragment__this_media_is_not_sent_yet, Toast.LENGTH_LONG).show() - return@Model - } - view.transitionName = "thumb" - val options = ActivityOptions.makeSceneTransitionAnimation(requireActivity(), view, "thumb") - startActivityForResult( - MediaIntentFactory.intentFromMediaRecord(requireContext(), mediaRecord, isLtr, allMediaInRail = true), - REQUEST_CODE_RETURN_FROM_MEDIA, - options.toBundle() - ) - } - ) - ) - - @Suppress("DEPRECATION") - clickPref( - title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all), - onClick = { - startActivityForResult(MediaOverviewActivity.forThread(requireContext(), state.threadId), REQUEST_CODE_RETURN_FROM_MEDIA) - } - ) - } - - if (state.recipient.isReleaseNotes) { - dividerPref() - sectionHeaderPref(R.string.preferences__help) - - externalLinkPref( - icon = DSLSettingsIcon.from(R.drawable.symbol_help_24), - title = DSLSettingsText.from(R.string.HelpSettingsFragment__support_center), - linkId = R.string.support_center_url - ) - clickPref( - icon = DSLSettingsIcon.from(R.drawable.symbol_invite_24), - title = DSLSettingsText.from(R.string.HelpSettingsFragment__contact_us), - onClick = { startActivity(AppSettingsActivity.help(requireContext())) } - ) - clickPref( - icon = DSLSettingsIcon.from(R.drawable.symbol_heart_24), - title = DSLSettingsText.from(R.string.preferences__donate_to_signal), - onClick = { startActivity(CheckoutFlowActivity.createIntent(requireContext(), InAppPaymentType.ONE_TIME_DONATION)) } - ) - } - - state.withRecipientSettingsState { recipientSettingsState -> - if (state.recipient.badges.isNotEmpty() && !state.recipient.isSelf) { - dividerPref() - - sectionHeaderPref(R.string.ManageProfileFragment_badges) - - displayBadges(requireContext(), state.recipient.badges) - - textPref( - summary = DSLSettingsText.from( - R.string.ConversationSettingsFragment__get_badges - ) - ) - } - - if (recipientSettingsState.selfHasGroups && !state.recipient.isReleaseNotes) { - dividerPref() - - val groupsInCommonCount = recipientSettingsState.allGroupsInCommon.size - sectionHeaderPref( - DSLSettingsText.from( - if (groupsInCommonCount == 0) { - getString(R.string.ManageRecipientActivity_no_groups_in_common) - } else { - resources.getQuantityString( - R.plurals.ManageRecipientActivity_d_groups_in_common, - groupsInCommonCount, - groupsInCommonCount - ) - } - ) - ) - - if (!state.recipient.isBlocked) { - customPref( - LargeIconClickPreference.Model( - title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_to_a_group), - icon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT), - isEnabled = !state.isDeprecatedOrUnregistered, - onClick = { - viewModel.onAddToGroup() - } - ) - ) - } - - for (group in recipientSettingsState.groupsInCommon) { - customPref( - RecipientPreference.Model( - recipient = group, - onRowClick = { - CommunicationActions.startConversation(requireActivity(), group, null) - if (requireActivity() !is MainNavigationChatDetailRouter) { - requireActivity().finish() - } - } - ) - ) - } - - if (recipientSettingsState.canShowMoreGroupsInCommon) { - customPref( - LargeIconClickPreference.Model( - title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all), - icon = DSLSettingsIcon.from(R.drawable.show_more, NO_TINT), - onClick = { - viewModel.revealAllMembers() - } - ) - ) - } - } - } - - state.withGroupSettingsState { groupState -> - val memberCount = groupState.allMembers.size - val canAdd = groupState.canAddToGroup && !groupState.isTerminated && !state.isDeprecatedOrUnregistered - - if (groupState.canAddToGroup || memberCount > 0) { - dividerPref() - - 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( - title = DSLSettingsText.from(memberHeaderText), - iconEnd = DSLSettingsIcon.from(CoreUiR.drawable.symbol_search_24), - onClick = { - val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberSearchFragment(groupState.groupId, canAdd, groupState.groupLinkEnabled) - navController.safeNavigate(action) - } - ) - } - - if (canAdd) { - customPref( - LargeIconClickPreference.Model( - title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_members), - icon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT), - onClick = { - viewModel.onAddToGroup() - } - ) - ) - } - - colorizer.onGroupMembershipChanged( - serviceIds = groupState.allMembers.mapNotNull { it.member.serviceId.orNull() } - ) - - for (member in groupState.members) { - val canSetMemberLabel = member.member.isSelf && groupState.canSetOwnMemberLabel - val memberLabel = member.getMemberLabel(groupState) - - customPref( - RecipientPreference.Model( - recipient = member.member, - isAdmin = member.isAdmin, - memberLabel = memberLabel, - canSetMemberLabel = canSetMemberLabel, - lifecycleOwner = viewLifecycleOwner, - onRowClick = { - if (canSetMemberLabel && memberLabel == null) { - val action = ConversationSettingsFragmentDirections - .actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId) - navController.safeNavigate(action) - } else { - RecipientBottomSheetDialogFragment.show(parentFragmentManager, member.member.id, groupState.groupId) - } - }, - onAvatarClick = { - RecipientBottomSheetDialogFragment.show(parentFragmentManager, member.member.id, groupState.groupId) - } - ) - ) - } - - if (groupState.canShowMoreGroupMembers) { - customPref( - LargeIconClickPreference.Model( - title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all), - icon = DSLSettingsIcon.from(R.drawable.show_more, NO_TINT), - onClick = { - viewModel.revealAllMembers() - } - ) - ) - } - - 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.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered, - onClick = { - navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToShareableGroupLinkFragment(groupState.groupId)) - } - ) - - clickPref( - title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_member_label), - icon = DSLSettingsIcon.from(R.drawable.symbol_tag_24), - isEnabled = groupState.canSetOwnMemberLabel && !state.isDeprecatedOrUnregistered, - onClick = { - val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId) - navController.safeNavigate(action) - }, - onDisabledClicked = { - Snackbar.make(requireView(), R.string.GroupMemberLabel__error_no_edit_permission, Snackbar.LENGTH_SHORT).show() - } - ) - - clickPref( - title = DSLSettingsText.from(R.string.ConversationSettingsFragment__requests_and_invites), - icon = DSLSettingsIcon.from(R.drawable.ic_update_group_add_16), - isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered, - onClick = { - startActivity(ManagePendingAndRequestingMembersActivity.newIntent(requireContext(), groupState.groupId.requireV2())) - } - ) - - if (groupState.isSelfAdmin) { - clickPref( - title = DSLSettingsText.from(R.string.ConversationSettingsFragment__permissions), - icon = DSLSettingsIcon.from(R.drawable.ic_lock_24), - isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered, - onClick = { - val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToPermissionsSettingsFragment(groupState.groupId) - navController.safeNavigate(action) - } - ) - } - } - - if (groupState.canLeave) { - dividerPref() - - clickPref( - title = DSLSettingsText.from(R.string.conversation__menu_leave_group, if (state.isDeprecatedOrUnregistered) alertDisabledTint else alertTint), - icon = DSLSettingsIcon.from(leaveIcon), - isEnabled = !state.isDeprecatedOrUnregistered, - onClick = { - LeaveGroupDialog.handleLeavePushGroup(requireActivity(), groupState.groupId.requirePush(), null) - } - ) - } - } - - 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() - goToConversationList() - } - ) - } - - 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() - goToConversationList() - } - } - ) - } - } - - if (state.canModifyBlockedState && !state.isTerminatedGroup) { - state.withRecipientSettingsState { - dividerPref() - } - - state.withGroupSettingsState { - if (!it.canLeave) { - dividerPref() - } - } - - val isBlocked = state.recipient.isBlocked - val isGroup = state.recipient.isPushGroup - - val title = when { - isBlocked && isGroup -> R.string.ConversationSettingsFragment__unblock_group - isBlocked -> R.string.ConversationSettingsFragment__unblock - isGroup -> R.string.ConversationSettingsFragment__block_group - else -> R.string.ConversationSettingsFragment__block - } - - val titleTint = if (isBlocked) { - null - } else if (state.isDeprecatedOrUnregistered) { - alertDisabledTint - } else { - alertTint - } - - clickPref( - title = if (titleTint != null) DSLSettingsText.from(title, titleTint) else DSLSettingsText.from(title), - icon = if (isBlocked) DSLSettingsIcon.from(R.drawable.symbol_block_24) else DSLSettingsIcon.from(blockIcon), - isEnabled = !state.isDeprecatedOrUnregistered, - onClick = { - if (state.recipient.isBlocked) { - BlockUnblockDialog.showUnblockFor(requireContext(), state.recipient) { - viewModel.unblock() - } - } else { - BlockUnblockDialog.showBlockFor(requireContext(), state.recipient) { - viewModel.block() - } - } - } - ) - - if (!state.recipient.isReleaseNotes) { - val reportSpamTint = if (state.isDeprecatedOrUnregistered) R.color.signal_alert_primary_50 else 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), - isEnabled = !state.isDeprecatedOrUnregistered, - onClick = { - BlockUnblockDialog.showReportSpamFor( - requireContext(), - state.recipient, - { - viewModel - .onReportSpam() - .subscribeBy { - Toast.makeText(requireContext(), R.string.ConversationFragment_reported_as_spam, Toast.LENGTH_SHORT).show() - goToConversationList() - } - .addTo(lifecycleDisposable) - }, - if (state.recipient.isBlocked) { - null - } else { - Runnable { - viewModel - .onBlockAndReportSpam() - .subscribeBy { result -> - when (result) { - is Result.Success -> { - Toast.makeText(requireContext(), R.string.ConversationFragment_reported_as_spam_and_blocked, Toast.LENGTH_SHORT).show() - goToConversationList() - } - - is Result.Failure -> { - Toast.makeText(requireContext(), GroupErrors.getUserDisplayMessage(result.failure), Toast.LENGTH_SHORT).show() - } - } - } - .addTo(lifecycleDisposable) - } - } - ) - } - ) - } - } - - 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(), - state.recipient, - { - viewModel - .onReportSpam() - .subscribeBy { - Toast.makeText(requireContext(), R.string.ConversationFragment_reported_as_spam, Toast.LENGTH_SHORT).show() - goToConversationList() - } - .addTo(lifecycleDisposable) - }, - null - ) - } - ) - } - } - - state.withGroupSettingsState { groupState -> - if (groupState.canEndGroup) { - 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) - } - ) - } - } - } - } - - private fun GroupMemberEntry.FullMember.getMemberLabel( - groupState: SpecificSettingsState.GroupSettingsState - ): StyledMemberLabel? { - return groupState.memberLabelsByRecipientId[member.id]?.let { label -> - val tintColor = colorizer.getIncomingGroupSenderColor(context = requireContext(), recipient = member) - StyledMemberLabel(label, tintColor) - } - } - - private fun formatDisappearingMessagesLifespan(disappearingMessagesLifespan: Int): String { - return if (disappearingMessagesLifespan <= 0) { - getString(R.string.preferences_off) - } else { - ExpirationUtil.getExpirationDisplayValue(requireContext(), disappearingMessagesLifespan) - } - } - - private fun handleAddToAGroup(addToAGroup: ConversationSettingsEvent.AddToAGroup) { - startActivity(AddToGroupsActivity.createIntent(requireContext(), addToAGroup.recipientId, addToAGroup.groupMembership)) - } - - @Suppress("DEPRECATION") - private fun handleAddMembersToGroup(addMembersToGroup: ConversationSettingsEvent.AddMembersToGroup) { - startActivityForResult( - AddMembersActivity.createIntent( - requireContext(), - addMembersToGroup - ), - REQUEST_CODE_ADD_MEMBERS_TO_GROUP - ) - } - - private fun showGroupHardLimitDialog() { - GroupLimitDialog.showHardLimitMessage(requireContext()) - } - - private fun showAddMembersToGroupError(showAddMembersToGroupError: ConversationSettingsEvent.ShowAddMembersToGroupError) { - Toast.makeText(requireContext(), GroupErrors.getUserDisplayMessage(showAddMembersToGroupError.failureReason), Toast.LENGTH_LONG).show() - } - - private fun showBlockGroupError(showBlockGroupError: ConversationSettingsEvent.ShowBlockGroupError) { - Toast.makeText(requireContext(), GroupErrors.getUserDisplayMessage(showBlockGroupError.failureReason), Toast.LENGTH_LONG).show() - } - - private fun showGroupInvitesSentDialog(showGroupInvitesSentDialog: ConversationSettingsEvent.ShowGroupInvitesSentDialog) { - if (showGroupInvitesSentDialog.invitesSentTo.isNotEmpty()) { - GroupInviteSentDialog.show(childFragmentManager, showGroupInvitesSentDialog.invitesSentTo) - } - } - - private fun showMembersAdded(showMembersAdded: ConversationSettingsEvent.ShowMembersAdded) { - val string = resources.getQuantityString( - R.plurals.ManageGroupActivity_added, - showMembersAdded.membersAddedCount, - showMembersAdded.membersAddedCount - ) - - Snackbar.make(requireView(), string, Snackbar.LENGTH_SHORT).show() - } - - private class ConversationSettingsOnUserScrolledAnimationHelper( - private val toolbarAvatar: View, - private val toolbarTitle: View, - private val toolbarBackground: View - ) : OnScrollAnimationHelper() { - - override val duration: Long = 200L - - private val actionBarSize = DimensionUnit.DP.toPixels(64f) - private val rect = Rect() - - override fun getAnimationState(recyclerView: RecyclerView): AnimationState { - val layoutManager = recyclerView.layoutManager!! - val firstVisibleItemPosition = if (layoutManager is FlexboxLayoutManager) { - layoutManager.findFirstVisibleItemPosition() - } else { - (layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() - } - - return if (firstVisibleItemPosition == 0) { - val firstChild = requireNotNull(layoutManager.getChildAt(0)) - firstChild.getLocalVisibleRect(rect) - - if (rect.height() <= actionBarSize) { - AnimationState.SHOW - } else { - AnimationState.HIDE - } - } else { - AnimationState.SHOW - } - } - - override fun show(duration: Long) { - toolbarAvatar - .animate() - .setDuration(duration) - .translationY(0f) - .alpha(1f) - - toolbarTitle - .animate() - .setDuration(duration) - .translationY(0f) - .alpha(1f) - - toolbarBackground - .animate() - .setDuration(duration) - .alpha(1f) - } - - override fun hide(duration: Long) { - toolbarAvatar - .animate() - .setDuration(duration) - .translationY(ViewUtil.dpToPx(56).toFloat()) - .alpha(0f) - - toolbarTitle - .animate() - .setDuration(duration) - .translationY(ViewUtil.dpToPx(56).toFloat()) - .alpha(0f) - - toolbarBackground - .animate() - .setDuration(duration) - .alpha(0f) - } - } - /** * Implemented by hosts that postpone enter transitions (for example, shared element flows). * diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsKind.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsKind.kt new file mode 100644 index 0000000000..46e3389f97 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsKind.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation + +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * Which conversation settings screen [ConversationSettingsFragment] should show. + * + * This is a fragment argument so the fragment can pick a view model without a database round-trip first. + */ +enum class ConversationSettingsKind { + INDIVIDUAL, + NOTE_TO_SELF, + RELEASE_NOTES, + GROUP; + + companion object { + @JvmStatic + fun from(recipient: Recipient): ConversationSettingsKind { + return when { + recipient.isSelf -> NOTE_TO_SELF + recipient.isReleaseNotes -> RELEASE_NOTES + recipient.isGroup -> GROUP + else -> INDIVIDUAL + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavHostFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavHostFragment.kt index 1b905944a9..908e946933 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavHostFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavHostFragment.kt @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.components.settings.conversation import android.os.Bundle import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentTransaction import androidx.navigation.fragment.NavHostFragment import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -22,13 +23,22 @@ import org.thoughtcrime.securesms.recipients.RecipientId class ConversationSettingsNavHostFragment : NavHostFragment(), FragmentBackPressedInfoProvider { companion object { + /** + * The fade/scale transition conversation settings animates in and out with, matching what the compose entry point + * gets from `TransitionSpecs.FadeScale`. + */ + fun FragmentTransaction.setConversationSettingsAnimations(): FragmentTransaction { + return setCustomAnimations(R.anim.fade_scale_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_scale_out) + } + suspend fun createArgs(recipientId: RecipientId): Bundle { val recipient = withContext(Dispatchers.Default) { Recipient.resolved(recipientId) } + val kind = ConversationSettingsKind.from(recipient) val args = if (recipient.isGroup) { - ConversationSettingsFragmentArgs.Builder(null, recipient.requireGroupId(), null) + ConversationSettingsFragmentArgs.Builder(null, recipient.requireGroupId(), null, kind) } else { - ConversationSettingsFragmentArgs.Builder(recipientId, null, null) + ConversationSettingsFragmentArgs.Builder(recipientId, null, null, kind) }.build() return bundleOf(DSLSettingsActivity.ARG_START_BUNDLE to args.toBundle()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavigator.kt index 9b4c1f04e8..488078feb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavigator.kt @@ -24,10 +24,10 @@ object ConversationSettingsNavigator { return } - val intent = if (recipient.isPushGroup) { + val intent = if (recipient.isGroup) { ConversationSettingsActivity.forGroup(activity, recipient.requireGroupId()) } else { - ConversationSettingsActivity.forRecipient(activity, recipient.id) + ConversationSettingsActivity.forRecipient(activity, recipient) } activity.startActivity(intent) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt index f315704107..a8f2c19cda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsRepository.kt @@ -1,124 +1,250 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + package org.thoughtcrime.securesms.components.settings.conversation import android.content.Context -import android.database.Cursor -import androidx.annotation.WorkerThread -import androidx.lifecycle.LiveData -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.schedulers.Schedulers -import kotlinx.coroutines.rx3.asObservable +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.rx3.asFlow +import kotlinx.coroutines.rx3.await +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import org.signal.core.util.Result +import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.logging.Log -import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup +import org.signal.core.util.orNull +import org.signal.core.util.readToList +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallEntry +import org.thoughtcrime.securesms.components.settings.conversation.shared.GroupMember +import org.thoughtcrime.securesms.components.settings.conversation.shared.LegacyGroupState import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery +import org.thoughtcrime.securesms.conversation.colors.ColorizerV2 import org.thoughtcrime.securesms.database.CallTable import org.thoughtcrime.securesms.database.MediaTable +import org.thoughtcrime.securesms.database.RxDatabaseObserver import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.GroupRecord import org.thoughtcrime.securesms.database.model.IdentityRecord -import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.groups.GroupChangeException import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.GroupProtoUtil import org.thoughtcrime.securesms.groups.GroupsInCommonRepository -import org.thoughtcrime.securesms.groups.LiveGroup +import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelRepository +import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason import org.thoughtcrime.securesms.groups.ui.GroupChangeResult +import org.thoughtcrime.securesms.groups.ui.GroupMemberOrder import org.thoughtcrime.securesms.groups.v2.GroupAddMembersResult import org.thoughtcrime.securesms.groups.v2.GroupManagementRepository import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientUtil +import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.util.RemoteConfig +import org.thoughtcrime.securesms.util.TextSecurePreferences import java.io.IOException +import kotlin.coroutines.resume -private val TAG = Log.tag(ConversationSettingsRepository::class.java) +private val TAG = Log.tag(ConversationSettingsRepository::class) +/** + * Data access shared by all four conversation settings screens. + */ class ConversationSettingsRepository( private val context: Context, - private val groupManagementRepository: GroupManagementRepository = GroupManagementRepository(context) + private val groupManagementRepository: GroupManagementRepository = GroupManagementRepository(context), + private val messageRequestRepository: MessageRequestRepository = MessageRequestRepository(context), + private val memberLabelRepository: MemberLabelRepository = MemberLabelRepository.instance ) { - fun getCallEvents(callRowIds: LongArray): Single>> { - return if (callRowIds.isEmpty()) { - Single.just(emptyList()) - } else { - Single.fromCallable { - val callMap = SignalDatabase.calls.getCallsByRowIds(callRowIds.toList()) - val messageIds = callMap.values.mapNotNull { it.messageId } - SignalDatabase.messages.getMessages(messageIds).iterator().asSequence() - .filter { callMap.containsKey(it.id) } - .map { callMap[it.id]!! to it } - .sortedByDescending { it.first.timestamp } - .toList() + fun isDeprecatedOrUnregistered(): Boolean { + return SignalStore.misc.isClientDeprecated || TextSecurePreferences.isUnauthorizedReceived(context) + } + + fun isInternalRecipientDetailsEnabled(): Boolean { + return SignalStore.internal.recipientDetails + } + + fun isStarredMessagesEnabled(): Boolean { + return SignalStore.labs.starredMessages + } + + fun isInternalUser(): Boolean { + return RemoteConfig.internalUser + } + + fun isAddToStoryAvailable(): Boolean { + return !SignalStore.story.isFeatureDisabled + } + + fun isStoriesFeatureEnabled(): Boolean { + return Stories.isFeatureEnabled() + } + + fun getSelfId(): RecipientId { + return Recipient.self().id + } + + fun isBlockable(recipient: Recipient): Boolean { + return RecipientUtil.isBlockable(recipient) + } + + fun observeRecipient(recipientId: RecipientId): Flow { + return Recipient.observable(recipientId).asFlow() + } + + /** + * Emits the group's recipient whenever it changes. The initial lookup of the group's [RecipientId] hits the database, + * so this can't be a plain [observeRecipient] call. + */ + fun observeGroupRecipient(groupId: GroupId): Flow { + return flow { + val recipientId = withContext(SignalDispatchers.Default) { Recipient.externalGroupExact(groupId).id } + emitAll(observeRecipient(recipientId)) + } + } + + /** + * Emits everything we can derive from the group's record. Recipient changes drive this, since a group change always + * touches its recipient. + */ + fun observeGroupDetails(groupId: GroupId): Flow { + return observeGroupRecipient(groupId).mapNotNull { recipient -> + withContext(SignalDispatchers.Default) { + SignalDatabase.groups.getGroup(recipient.id).orNull()?.let { buildGroupDetails(groupId, recipient, it) } } } } - @WorkerThread - fun getThreadMedia(threadId: Long, limit: Int): Cursor? { - return if (threadId > 0) { - SignalDatabase.media.getGalleryMediaForThread(threadId, MediaTable.Sorting.Newest, limit) - } else { - null + fun observeStoryViewState(recipientId: RecipientId): Flow { + return StoryViewState.getForRecipientId(recipientId).asFlow() + } + + fun observeStoryViewState(groupId: GroupId): Flow { + return flow { + val recipientId = withContext(SignalDispatchers.Default) { Recipient.externalGroupExact(groupId).id } + emitAll(observeStoryViewState(recipientId)) } } - fun getStoryViewState(groupId: GroupId): Observable { - return Observable.fromCallable { - SignalDatabase.recipients.getByGroupId(groupId) - }.flatMap { - StoryViewState.getForRecipientId(it.get()) - }.observeOn(Schedulers.io()) - } - - fun getThreadId(recipientId: RecipientId, consumer: (Long) -> Unit) { - SignalExecutors.BOUNDED.execute { - consumer(SignalDatabase.threads.getThreadIdIfExistsFor(recipientId)) - } - } - - fun getThreadId(groupId: GroupId, consumer: (Long) -> Unit) { - SignalExecutors.BOUNDED.execute { - val recipientId = Recipient.externalGroupExact(groupId).id - consumer(SignalDatabase.threads.getThreadIdIfExistsFor(recipientId)) - } - } - - fun isInternalRecipientDetailsEnabled(): Boolean = SignalStore.internal.recipientDetails - - fun hasGroups(consumer: (Boolean) -> Unit) { - SignalExecutors.BOUNDED.execute { consumer(SignalDatabase.groups.getActiveGroupCount() > 0) } - } - - fun getIdentity(recipientId: RecipientId, consumer: (IdentityRecord?) -> Unit) { - SignalExecutors.BOUNDED.execute { - if (SignalStore.account.aci != null && SignalStore.account.pni != null) { - consumer(AppDependencies.protocolStore.aci().identities().getIdentityRecord(recipientId).orElse(null)) - } else { - consumer(null) - } - } - } - - fun getGroupsInCommon(recipientId: RecipientId): Observable> { + fun observeGroupsInCommon(recipientId: RecipientId): Flow> { return GroupsInCommonRepository.getGroupsInCommon(context, recipientId) - .asObservable() } - fun getGroupMembership(recipientId: RecipientId, consumer: (List) -> Unit) { - SignalExecutors.BOUNDED.execute { - val groupDatabase = SignalDatabase.groups - val groupRecords = groupDatabase.getPushGroupsContainingMember(recipientId) - val groupRecipients = ArrayList(groupRecords.size) - for (groupRecord in groupRecords) { - groupRecipients.add(groupRecord.recipientId) - } - consumer(groupRecipients) + /** Emits the calls backing the call-info variant of this screen every time the conversation changes. */ + fun observeCalls(threadId: Long, callRowIds: LongArray): Flow> { + return RxDatabaseObserver.conversation(threadId).toObservable().asFlow().map { getCallEntries(callRowIds) } + } + + /** [observeCalls], but re-subscribing as the caller learns which thread it's looking at. */ + @OptIn(ExperimentalCoroutinesApi::class) + fun observeCalls(threadIdFlow: Flow, callRowIds: LongArray): Flow> { + return threadIdFlow + .distinctUntilChanged() + .filter { it > 0 } + .flatMapLatest { observeCalls(it, callRowIds) } + } + + suspend fun getCallEntries(callRowIds: LongArray): List { + if (callRowIds.isEmpty()) { + return emptyList() } + + return withContext(SignalDispatchers.Default) { + val callMap: Map = SignalDatabase.calls.getCallsByRowIds(callRowIds.toList()) + val messageIds = callMap.values.mapNotNull { it.messageId } + + SignalDatabase.messages + .getMessages(messageIds) + .iterator() + .asSequence() + .filter { callMap.containsKey(it.id) } + .map { CallEntry(callMap.getValue(it.id), it) } + .sortedByDescending { it.call.timestamp } + .toList() + } + } + + suspend fun getSharedMedia(threadId: Long, limit: Int): List { + if (threadId <= 0) { + return emptyList() + } + + return withContext(SignalDispatchers.Default) { + SignalDatabase.media + .getGalleryMediaForThread(threadId, MediaTable.Sorting.Newest, limit) + ?.readToList { MediaTable.MediaRecord.from(it) } + ?: emptyList() + } + } + + suspend fun getThreadId(recipientId: RecipientId): Long { + return withContext(SignalDispatchers.Default) { + SignalDatabase.threads.getThreadIdIfExistsFor(recipientId) + } + } + + suspend fun getThreadId(groupId: GroupId): Long { + return withContext(SignalDispatchers.Default) { + SignalDatabase.threads.getThreadIdIfExistsFor(Recipient.externalGroupExact(groupId).id) + } + } + + suspend fun hasGroups(): Boolean { + return withContext(SignalDispatchers.Default) { + SignalDatabase.groups.getActiveGroupCount() > 0 + } + } + + suspend fun getIdentity(recipientId: RecipientId): IdentityRecord? { + return withContext(SignalDispatchers.Default) { + if (SignalStore.account.aci != null && SignalStore.account.pni != null) { + AppDependencies.protocolStore.aci().identities().getIdentityRecord(recipientId).orNull() + } else { + null + } + } + } + + suspend fun getGroupMembership(recipientId: RecipientId): List { + return withContext(SignalDispatchers.Default) { + SignalDatabase.groups.getPushGroupsContainingMember(recipientId).map { it.recipientId } + } + } + + suspend fun getMemberLabels(groupId: GroupId.V2, members: List): Map { + val labels = memberLabelRepository.getLabels(groupId, members) + if (labels.isEmpty()) { + return emptyMap() + } + + return withContext(SignalDispatchers.Default) { + val colorizer = ColorizerV2(members.mapNotNull { it.serviceId.orNull() }) + + members + .mapNotNull { member -> labels[member.id]?.let { member to it } } + .associate { (member, label) -> member.id to StyledMemberLabel(label, colorizer.getIncomingGroupSenderColor(context, member)) } + } + } + + suspend fun canSetOwnMemberLabel(groupId: GroupId.V2): Boolean { + return memberLabelRepository.canSetLabel(groupId, Recipient.self()) } fun refreshRecipient(recipientId: RecipientId) { @@ -131,109 +257,181 @@ class ConversationSettingsRepository( } } - fun setMuteUntil(recipientId: RecipientId, until: Long) { - SignalExecutors.BOUNDED.execute { + suspend fun setMuteUntil(recipientId: RecipientId, until: Long) { + withContext(SignalDispatchers.Default) { SignalDatabase.recipients.setMuted(recipientId, until) } } - fun getGroupCapacity(groupId: GroupId, consumer: (GroupCapacityResult) -> Unit) { - SignalExecutors.BOUNDED.execute { - val groupRecord: GroupRecord = SignalDatabase.groups.getGroup(groupId).get() - consumer( - if (groupRecord.hasV2GroupProperties) { - val decryptedGroup: DecryptedGroup = groupRecord.requireV2GroupProperties().decryptedGroup - val pendingMembers: List = decryptedGroup.pendingMembers - .map { m -> m.serviceIdBytes } - .map { s -> GroupProtoUtil.serviceIdBinaryToRecipientId(s) } - - val members = mutableListOf() - - members.addAll(groupRecord.members) - members.addAll(pendingMembers) - - GroupCapacityResult(Recipient.self().id, members, RemoteConfig.groupLimits, groupRecord.isAnnouncementGroup) - } else { - GroupCapacityResult(Recipient.self().id, groupRecord.members, RemoteConfig.groupLimits, false) - } - ) + suspend fun setMuteUntil(groupId: GroupId, until: Long) { + withContext(SignalDispatchers.Default) { + SignalDatabase.recipients.setMuted(Recipient.externalGroupExact(groupId).id, until) } } - fun addMembers(groupId: GroupId, selected: List, consumer: (GroupAddMembersResult) -> Unit) { - groupManagementRepository.addMembers(groupId, selected, consumer) - } + suspend fun getGroupCapacity(groupId: GroupId): GroupCapacityResult? { + return withContext(SignalDispatchers.Default) { + val groupRecord: GroupRecord = SignalDatabase.groups.getGroup(groupId).orNull() ?: return@withContext null - fun setMuteUntil(groupId: GroupId, until: Long) { - SignalExecutors.BOUNDED.execute { - val recipientId = Recipient.externalGroupExact(groupId).id - SignalDatabase.recipients.setMuted(recipientId, until) - } - } + if (groupRecord.hasV2GroupProperties) { + val pendingMembers: List = groupRecord + .requireV2GroupProperties() + .decryptedGroup + .pendingMembers + .map { GroupProtoUtil.serviceIdBinaryToRecipientId(it.serviceIdBytes) } - @WorkerThread - fun block(recipientId: RecipientId): GroupChangeResult { - return try { - val recipient = Recipient.resolved(recipientId) - if (recipient.isGroup) { - RecipientUtil.block(context, recipient) + GroupCapacityResult(Recipient.self().id, groupRecord.members + pendingMembers, RemoteConfig.groupLimits, groupRecord.isAnnouncementGroup) } else { - RecipientUtil.blockNonGroup(context, recipient) + GroupCapacityResult(Recipient.self().id, groupRecord.members, RemoteConfig.groupLimits, false) } - GroupChangeResult.SUCCESS - } catch (e: Exception) { - Log.w(TAG, "Failed to block recipient.", e) - GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) } } - fun unblock(recipientId: RecipientId) { - SignalExecutors.BOUNDED.execute { - val recipient = Recipient.resolved(recipientId) - RecipientUtil.unblock(recipient) + suspend fun addMembers(groupId: GroupId, selected: List): GroupAddMembersResult { + return suspendCancellableCoroutine { continuation -> + groupManagementRepository.addMembers(groupId, selected) { continuation.resume(it) } } } - @WorkerThread - fun block(groupId: GroupId): GroupChangeResult { - return try { - val recipient = Recipient.externalGroupExact(groupId) - RecipientUtil.block(context, recipient) - GroupChangeResult.SUCCESS - } catch (e: Exception) { - Log.w(TAG, "Failed to block group.", e) - GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) + suspend fun block(recipientId: RecipientId): GroupChangeResult { + return withContext(SignalDispatchers.IO) { + try { + val recipient = Recipient.resolved(recipientId) + if (recipient.isGroup) { + RecipientUtil.block(context, recipient) + } else { + RecipientUtil.blockNonGroup(context, recipient) + } + GroupChangeResult.SUCCESS + } catch (e: IOException) { + Log.w(TAG, "Failed to block recipient.", e) + GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) + } catch (e: GroupChangeException) { + Log.w(TAG, "Failed to block recipient.", e) + GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) + } } } - fun unblock(groupId: GroupId) { - SignalExecutors.BOUNDED.execute { - val recipient = Recipient.externalGroupExact(groupId) - RecipientUtil.unblock(recipient) + suspend fun block(groupId: GroupId): GroupChangeResult { + return withContext(SignalDispatchers.IO) { + try { + RecipientUtil.block(context, Recipient.externalGroupExact(groupId)) + GroupChangeResult.SUCCESS + } catch (e: IOException) { + Log.w(TAG, "Failed to block group.", e) + GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) + } catch (e: GroupChangeException) { + Log.w(TAG, "Failed to block group.", e) + GroupChangeResult.failure(GroupChangeFailureReason.fromException(e)) + } } } - @WorkerThread - fun isMessageRequestAccepted(recipient: Recipient): Boolean { - return RecipientUtil.isMessageRequestAccepted(recipient) + suspend fun unblock(recipientId: RecipientId) { + withContext(SignalDispatchers.Default) { + RecipientUtil.unblock(Recipient.resolved(recipientId)) + } } - fun getMembershipCountDescription(liveGroup: LiveGroup): LiveData { - return liveGroup.getMembershipCountDescription(context.resources) + suspend fun unblock(groupId: GroupId) { + withContext(SignalDispatchers.Default) { + RecipientUtil.unblock(Recipient.externalGroupExact(groupId)) + } } - @WorkerThread - fun isArchived(recipientId: RecipientId): Boolean { - return SignalDatabase.threads.isArchived(recipientId) + suspend fun reportSpam(recipientId: RecipientId, threadId: Long) { + messageRequestRepository.reportSpamMessageRequest(recipientId, threadId).await() } - @WorkerThread - fun setArchived(threadId: Long, archived: Boolean) { - SignalDatabase.threads.setArchived(setOf(threadId), archived) + suspend fun blockAndReportSpam(recipientId: RecipientId, threadId: Long): Result { + return messageRequestRepository.blockAndReportSpamMessageRequest(recipientId, threadId).await() } - @WorkerThread - fun deleteChat(threadId: Long) { - SignalDatabase.threads.deleteConversation(threadId) + suspend fun isArchived(recipientId: RecipientId): Boolean { + return withContext(SignalDispatchers.Default) { + SignalDatabase.threads.isArchived(recipientId) + } + } + + suspend fun setArchived(threadId: Long, archived: Boolean) { + withContext(SignalDispatchers.Default) { + SignalDatabase.threads.setArchived(setOf(threadId), archived) + } + } + + suspend fun deleteChat(threadId: Long) { + withContext(SignalDispatchers.Default) { + SignalDatabase.threads.deleteConversation(threadId) + } + } + + private fun buildGroupDetails(groupId: GroupId, recipient: Recipient, record: GroupRecord): GroupDetails { + val self = Recipient.self() + val selfMemberLevel = record.memberLevel(self) + val members = record.members + .map { Recipient.resolved(it) } + .map { GroupMember(recipient = it, isAdmin = record.isAdmin(it)) } + .sortedWith(MEMBER_ORDER) + + val pendingMemberCount = if (record.hasV2GroupProperties) { + record.requireV2GroupProperties().decryptedGroup.pendingMembers.size + } else { + 0 + } + + return GroupDetails( + recipient = recipient, + title = record.title?.takeIf { it.isNotEmpty() } ?: recipient.getDisplayName(context), + description = record.description, + descriptionShouldLinkify = RecipientUtil.isMessageRequestAccepted(recipient), + members = members, + isSelfAdmin = record.isAdmin(self), + canEditGroupAttributes = record.isActive && record.attributesAccessControl.allows(selfMemberLevel), + canAddMembers = record.isActive && record.membershipAdditionAccessControl.allows(selfMemberLevel), + isActive = record.isActive, + isTerminated = record.isTerminated, + isAnnouncementGroup = record.isAnnouncementGroup, + groupLinkEnabled = record.isGroupLinkEnabled, + membershipCountDescription = membershipCountDescription(pendingMemberCount, members.size), + legacyGroupState = if (groupId.isMms) LegacyGroupState.MMS_WARNING else LegacyGroupState.NONE + ) + } + + private fun membershipCountDescription(invitedCount: Int, fullMemberCount: Int): String { + val resources = context.resources + return if (invitedCount > 0) { + val invited = resources.getQuantityString(R.plurals.MessageRequestProfileView_invited, invitedCount, invitedCount) + resources.getQuantityString(R.plurals.MessageRequestProfileView_members_and_invited, fullMemberCount, fullMemberCount, invited) + } else { + resources.getQuantityString(R.plurals.MessageRequestProfileView_members, fullMemberCount, fullMemberCount) + } + } + + /** Everything about a group that we can pull off of its [GroupRecord] and [Recipient]. */ + data class GroupDetails( + val recipient: Recipient, + val title: String, + val description: String?, + val descriptionShouldLinkify: Boolean, + val members: List, + val isSelfAdmin: Boolean, + val canEditGroupAttributes: Boolean, + val canAddMembers: Boolean, + val isActive: Boolean, + val isTerminated: Boolean, + val isAnnouncementGroup: Boolean, + val groupLinkEnabled: Boolean, + val membershipCountDescription: String, + val legacyGroupState: LegacyGroupState + ) + + companion object { + private val MEMBER_ORDER: Comparator = GroupMemberOrder.comparator( + { it.recipient.isSelf }, + { it.isAdmin }, + { it.recipient.hasAUserSetDisplayName(AppDependencies.application) }, + { it.recipient.getDisplayName(AppDependencies.application) } + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt deleted file mode 100644 index 027a0a1e47..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsState.kt +++ /dev/null @@ -1,110 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.conversation - -import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference -import org.thoughtcrime.securesms.components.settings.conversation.preferences.CallPreference -import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference -import org.thoughtcrime.securesms.database.MediaTable -import org.thoughtcrime.securesms.database.model.IdentityRecord -import org.thoughtcrime.securesms.database.model.StoryViewState -import org.thoughtcrime.securesms.groups.GroupId -import org.thoughtcrime.securesms.groups.memberlabel.MemberLabel -import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry -import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.recipients.RecipientId - -data class ConversationSettingsState( - val threadId: Long = -1, - val storyViewState: StoryViewState = StoryViewState.NONE, - val recipient: Recipient = Recipient.UNKNOWN, - val isDeprecatedOrUnregistered: Boolean = false, - val buttonStripState: ButtonStripPreference.State = ButtonStripPreference.State(), - val disappearingMessagesLifespan: Int = 0, - val canModifyBlockedState: Boolean = false, - val isArchived: Boolean = false, - val sharedMedia: List = emptyList(), - val sharedMediaIds: List = listOf(), - val displayInternalRecipientDetails: Boolean = false, - val calls: List = emptyList(), - private val sharedMediaLoaded: Boolean = false, - private val specificSettingsState: SpecificSettingsState -) { - - 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) { - consumer(specificSettingsState) - } - } - - fun withGroupSettingsState(consumer: (SpecificSettingsState.GroupSettingsState) -> Unit) { - if (specificSettingsState is SpecificSettingsState.GroupSettingsState) { - consumer(specificSettingsState) - } - } - - fun requireRecipientSettingsState(): SpecificSettingsState.RecipientSettingsState = specificSettingsState.requireRecipientSettingsState() - fun requireGroupSettingsState(): SpecificSettingsState.GroupSettingsState = specificSettingsState.requireGroupSettingsState() -} - -sealed class SpecificSettingsState { - - abstract val isLoaded: Boolean - - data class RecipientSettingsState( - val identityRecord: IdentityRecord? = null, - val allGroupsInCommon: List = listOf(), - val groupsInCommon: List = listOf(), - val selfHasGroups: Boolean = false, - val canShowMoreGroupsInCommon: Boolean = false, - val groupsInCommonExpanded: Boolean = false, - val contactLinkState: ContactLinkState = ContactLinkState.NONE - ) : SpecificSettingsState() { - - override val isLoaded: Boolean = true - - override fun requireRecipientSettingsState() = this - } - - data class GroupSettingsState( - val groupId: GroupId, - val allMembers: List = listOf(), - val members: List = listOf(), - 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, - val groupTitle: String = "", - private val groupTitleLoaded: Boolean = false, - val groupDescription: String? = null, - val groupDescriptionShouldLinkify: Boolean = false, - private val groupDescriptionLoaded: Boolean = false, - val groupLinkEnabled: Boolean = false, - val membershipCountDescription: String = "", - val legacyGroupState: LegacyGroupPreference.State = LegacyGroupPreference.State.NONE, - val isAnnouncementGroup: Boolean = false, - val memberLabelsByRecipientId: Map = emptyMap(), - val canSetOwnMemberLabel: Boolean = false - ) : SpecificSettingsState() { - - val canEndGroup: Boolean get() = isActive && groupId.isV2 && isSelfAdmin - - override val isLoaded: Boolean = groupTitleLoaded && groupDescriptionLoaded - - override fun requireGroupSettingsState(): GroupSettingsState = this - } - - open fun requireRecipientSettingsState(): RecipientSettingsState = error("Not a recipient settings state") - open fun requireGroupSettingsState(): GroupSettingsState = error("Not a group settings state") -} - -enum class ContactLinkState { - OPEN, - ADD, - NONE -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt deleted file mode 100644 index 0267d0c964..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsViewModel.kt +++ /dev/null @@ -1,601 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.conversation - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map -import androidx.lifecycle.viewModelScope -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Maybe -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.kotlin.plusAssign -import io.reactivex.rxjava3.subjects.PublishSubject -import io.reactivex.rxjava3.subjects.Subject -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.signal.core.util.Result -import org.signal.core.util.ThreadUtil -import org.signal.core.util.concurrent.SignalDispatchers -import org.signal.core.util.concurrent.SignalExecutors -import org.signal.core.util.readToList -import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference -import org.thoughtcrime.securesms.components.settings.conversation.preferences.CallPreference -import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference -import org.thoughtcrime.securesms.database.MediaTable -import org.thoughtcrime.securesms.database.RecipientTable -import org.thoughtcrime.securesms.database.RxDatabaseObserver -import org.thoughtcrime.securesms.database.model.StoryViewState -import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.thoughtcrime.securesms.groups.GroupId -import org.thoughtcrime.securesms.groups.LiveGroup -import org.thoughtcrime.securesms.groups.SelectionLimits -import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelRepository -import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason -import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry -import org.thoughtcrime.securesms.groups.v2.GroupAddMembersResult -import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository -import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.recipients.RecipientId -import org.thoughtcrime.securesms.recipients.RecipientUtil -import org.thoughtcrime.securesms.util.TextSecurePreferences -import org.thoughtcrime.securesms.util.livedata.LiveDataUtil -import org.thoughtcrime.securesms.util.livedata.Store - -sealed class ConversationSettingsViewModel( - private val callMessageIds: LongArray, - private val repository: ConversationSettingsRepository, - private val messageRequestRepository: MessageRequestRepository, - specificSettingsState: SpecificSettingsState -) : ViewModel() { - - @Volatile - private var cleared = false - - protected val store = Store( - ConversationSettingsState( - specificSettingsState = specificSettingsState, - isDeprecatedOrUnregistered = SignalStore.misc.isClientDeprecated || TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application) - ) - ) - protected val internalEvents: Subject = PublishSubject.create() - - private val sharedMediaUpdateTrigger = MutableLiveData(Unit) - - val state: LiveData = store.stateLiveData - val events: Observable = internalEvents.observeOn(AndroidSchedulers.mainThread()) - - protected val disposable = CompositeDisposable() - - init { - val threadId: LiveData = state.map { it.threadId }.distinctUntilChanged() - val updater: LiveData = LiveDataUtil.combineLatest(threadId, sharedMediaUpdateTrigger) { tId, _ -> tId } - - val sharedMedia: LiveData> = LiveDataUtil.mapAsync(SignalExecutors.BOUNDED, updater) { tId -> - repository.getThreadMedia(threadId = tId, limit = 100)?.readToList { cursor -> - MediaTable.MediaRecord.from(cursor) - } ?: emptyList() - } - - store.update(sharedMedia) { mediaRecords, state -> - if (!cleared) { - state.copy( - sharedMedia = mediaRecords, - sharedMediaIds = mediaRecords.mapNotNull { it.attachment?.attachmentId?.id }, - sharedMediaLoaded = true, - displayInternalRecipientDetails = repository.isInternalRecipientDetailsEnabled() - ) - } else { - state.copy(sharedMedia = emptyList()) - } - } - } - - fun refreshSharedMedia() { - sharedMediaUpdateTrigger.postValue(Unit) - } - - fun observeConversationForCallUpdates(threadId: Long) { - disposable += RxDatabaseObserver.conversation(threadId) - .toObservable() - .switchMapSingle { repository.getCallEvents(callMessageIds) } - .subscribe { callRecords -> - store.update { state -> - state.copy(calls = callRecords.map { (call, messageRecord) -> CallPreference.Model(call, messageRecord) }) - } - } - } - - fun onReportSpam(): Maybe { - return if (store.state.threadId > 0 && store.state.recipient != Recipient.UNKNOWN) { - messageRequestRepository.reportSpamMessageRequest(store.state.recipient.id, store.state.threadId) - .observeOn(AndroidSchedulers.mainThread()) - .toSingle { Unit } - .toMaybe() - } else { - Maybe.empty() - } - } - - fun onBlockAndReportSpam(): Maybe> { - return if (store.state.threadId > 0 && store.state.recipient != Recipient.UNKNOWN) { - messageRequestRepository.blockAndReportSpamMessageRequest(store.state.recipient.id, store.state.threadId) - .observeOn(AndroidSchedulers.mainThread()) - .toMaybe() - } else { - Maybe.empty() - } - } - - open fun refreshRecipient(): Unit = error("This ViewModel does not support this interaction") - - abstract fun setMuteUntil(muteUntil: Long) - - abstract fun unmute() - - abstract fun block() - - abstract fun unblock() - - abstract fun onAddToGroup() - - abstract fun onAddToGroupComplete(selected: List, onComplete: () -> Unit) - - abstract fun revealAllMembers() - - override fun onCleared() { - cleared = true - store.clear() - 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.Default) { - 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, - private val repository: ConversationSettingsRepository, - messageRequestRepository: MessageRequestRepository - ) : ConversationSettingsViewModel( - callMessageIds, - repository, - messageRequestRepository, - SpecificSettingsState.RecipientSettingsState() - ) { - - private val liveRecipient = Recipient.live(recipientId) - - init { - disposable += StoryViewState.getForRecipientId(recipientId).subscribe { storyViewState -> - store.update { it.copy(storyViewState = storyViewState) } - } - - store.update(liveRecipient.liveData) { recipient, state -> - val isAudioAvailable = recipient.isRegistered && - !recipient.isGroup && - !recipient.isBlocked && - !recipient.isSelf && - !recipient.isReleaseNotes - - state.copy( - recipient = recipient, - buttonStripState = ButtonStripPreference.State( - isMessageAvailable = callMessageIds.isNotEmpty(), - isVideoAvailable = recipient.registered == RecipientTable.RegisteredState.REGISTERED && !recipient.isSelf && !recipient.isBlocked && !recipient.isReleaseNotes, - isAudioAvailable = isAudioAvailable, - isAudioSecure = recipient.registered == RecipientTable.RegisteredState.REGISTERED, - isMuted = recipient.isMuted, - isMuteAvailable = !recipient.isSelf, - isSearchAvailable = callMessageIds.isEmpty() - ), - disappearingMessagesLifespan = recipient.expiresInSeconds, - canModifyBlockedState = !recipient.isSelf && RecipientUtil.isBlockable(recipient), - specificSettingsState = state.requireRecipientSettingsState().copy( - contactLinkState = when { - recipient.isSelf || recipient.isReleaseNotes || recipient.isBlocked -> ContactLinkState.NONE - recipient.isSystemContact -> ContactLinkState.OPEN - recipient.hasE164 && recipient.shouldShowE164 -> ContactLinkState.ADD - else -> ContactLinkState.NONE - } - ) - ) - } - - repository.getThreadId(recipientId) { threadId -> - store.update { state -> - state.copy(threadId = threadId) - } - observeConversationForCallUpdates(threadId) - } - - if (recipientId != Recipient.self().id) { - disposable += repository.getGroupsInCommon(recipientId).subscribe { groupsInCommon -> - store.update { state -> - val recipientSettings = state.requireRecipientSettingsState() - val canShowMore = !recipientSettings.groupsInCommonExpanded && groupsInCommon.size > 6 - - state.copy( - specificSettingsState = recipientSettings.copy( - allGroupsInCommon = groupsInCommon, - groupsInCommon = if (!canShowMore) groupsInCommon else groupsInCommon.take(5), - canShowMoreGroupsInCommon = canShowMore - ) - ) - } - } - - repository.hasGroups { hasGroups -> - store.update { state -> - val recipientSettings = state.requireRecipientSettingsState() - state.copy( - specificSettingsState = recipientSettings.copy( - selfHasGroups = hasGroups - ) - ) - } - } - - repository.getIdentity(recipientId) { identityRecord -> - store.update { state -> - state.copy(specificSettingsState = state.requireRecipientSettingsState().copy(identityRecord = identityRecord)) - } - } - } - } - - override fun onAddToGroup() { - repository.getGroupMembership(recipientId) { - internalEvents.onNext(ConversationSettingsEvent.AddToAGroup(recipientId, it)) - } - } - - override fun onAddToGroupComplete(selected: List, onComplete: () -> Unit) { - } - - override fun revealAllMembers() { - store.update { state -> - state.copy( - specificSettingsState = state.requireRecipientSettingsState().copy( - groupsInCommon = state.requireRecipientSettingsState().allGroupsInCommon, - groupsInCommonExpanded = true, - canShowMoreGroupsInCommon = false - ) - ) - } - } - - override fun refreshRecipient() { - repository.refreshRecipient(recipientId) - } - - override fun setMuteUntil(muteUntil: Long) { - repository.setMuteUntil(recipientId, muteUntil) - } - - override fun unmute() { - repository.setMuteUntil(recipientId, 0) - } - - override fun block() { - viewModelScope.launch { - val result = withContext(SignalDispatchers.IO) { - repository.block(recipientId) - } - - if (!result.isSuccess) { - internalEvents.onNext(ConversationSettingsEvent.ShowBlockGroupError(result.getFailureReason())) - } - } - } - - override fun unblock() { - repository.unblock(recipientId) - } - } - - private class GroupSettingsViewModel( - private val groupId: GroupId, - private val callMessageIds: LongArray, - private val repository: ConversationSettingsRepository, - messageRequestRepository: MessageRequestRepository - ) : ConversationSettingsViewModel(callMessageIds, repository, messageRequestRepository, SpecificSettingsState.GroupSettingsState(groupId)) { - - private val liveGroup = LiveGroup(groupId) - - init { - disposable += repository.getStoryViewState(groupId).subscribe { storyViewState -> - store.update { it.copy(storyViewState = storyViewState) } - } - - store.update(liveGroup.groupRecipient) { recipient, state -> - state.copy( - recipient = recipient, - buttonStripState = ButtonStripPreference.State( - isMessageAvailable = callMessageIds.isNotEmpty(), - 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 && recipient.isActiveGroup && !SignalStore.story.isFeatureDisabled - ), - canModifyBlockedState = RecipientUtil.isBlockable(recipient), - isArchived = repository.isArchived(recipient.id), - specificSettingsState = state.requireGroupSettingsState().copy( - legacyGroupState = getLegacyGroupState() - ) - ) - } - - repository.getThreadId(groupId) { threadId -> - store.update { state -> - state.copy(threadId = threadId) - } - observeConversationForCallUpdates(threadId) - } - - store.update(liveGroup.selfCanEditGroupAttributes()) { selfCanEditGroupAttributes, state -> - state.copy( - specificSettingsState = state.requireGroupSettingsState().copy( - canEditGroupAttributes = selfCanEditGroupAttributes - ) - ) - } - - store.update(liveGroup.isSelfAdmin) { isSelfAdmin, state -> - state.copy( - specificSettingsState = state.requireGroupSettingsState().copy( - isSelfAdmin = isSelfAdmin - ) - ) - } - - store.update(liveGroup.expireMessages) { expireMessages, state -> - state.copy( - disappearingMessagesLifespan = expireMessages - ) - } - - store.update(liveGroup.selfCanAddMembers()) { canAddMembers, state -> - state.copy( - specificSettingsState = state.requireGroupSettingsState().copy( - canAddToGroup = canAddMembers - ) - ) - } - - store.update(liveGroup.fullMembers) { fullMembers, state -> - val groupState = state.requireGroupSettingsState() - val canShowMore = !groupState.groupMembersExpanded && fullMembers.size > 6 - - if (groupId.isV2) { - loadMemberLabels(groupId.requireV2(), fullMembers) - loadCanSetMemberLabel(groupId.requireV2()) - } - - state.copy( - specificSettingsState = groupState.copy( - allMembers = fullMembers, - members = if (!canShowMore) fullMembers else fullMembers.take(5), - canShowMoreGroupMembers = canShowMore - ) - ) - } - - store.update(liveGroup.isAnnouncementGroup) { announcementGroup, state -> - state.copy( - specificSettingsState = state.requireGroupSettingsState().copy( - isAnnouncementGroup = announcementGroup - ) - ) - } - - val isMessageRequestAccepted: LiveData = LiveDataUtil.mapAsync(liveGroup.groupRecipient) { r -> repository.isMessageRequestAccepted(r) } - val descriptionState: LiveData = LiveDataUtil.combineLatest(liveGroup.description, isMessageRequestAccepted, ::DescriptionState) - - store.update(descriptionState) { d, state -> - state.copy( - specificSettingsState = state.requireGroupSettingsState().copy( - groupDescription = d.description, - groupDescriptionShouldLinkify = d.canLinkify, - groupDescriptionLoaded = true - ) - ) - } - - 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( - groupTitle = title, - groupTitleLoaded = true - ) - ) - } - - store.update(liveGroup.groupLink) { groupLink, state -> - state.copy( - specificSettingsState = state.requireGroupSettingsState().copy( - groupLinkEnabled = groupLink.isEnabled - ) - ) - } - - store.update(repository.getMembershipCountDescription(liveGroup)) { description, state -> - state.copy( - specificSettingsState = state.requireGroupSettingsState().copy( - membershipCountDescription = description - ) - ) - } - } - - private fun getLegacyGroupState(): LegacyGroupPreference.State { - return if (groupId.isMms) { - LegacyGroupPreference.State.MMS_WARNING - } else { - LegacyGroupPreference.State.NONE - } - } - - override fun onAddToGroup() { - repository.getGroupCapacity(groupId) { capacityResult -> - if (capacityResult.getRemainingCapacity() > 0) { - internalEvents.onNext( - ConversationSettingsEvent.AddMembersToGroup( - groupId, - SelectionLimits(capacityResult.getSelectionWarning(), capacityResult.getSelectionLimit()), - capacityResult.getMembersWithoutSelf() - ) - ) - } else { - internalEvents.onNext(ConversationSettingsEvent.ShowGroupHardLimitDialog) - } - } - } - - override fun onAddToGroupComplete(selected: List, onComplete: () -> Unit) { - repository.addMembers(groupId, selected) { - ThreadUtil.runOnMain { onComplete() } - - when (it) { - is GroupAddMembersResult.Success -> { - if (it.newMembersInvited.isNotEmpty()) { - internalEvents.onNext(ConversationSettingsEvent.ShowGroupInvitesSentDialog(it.newMembersInvited)) - } - - if (it.numberOfMembersAdded > 0) { - internalEvents.onNext(ConversationSettingsEvent.ShowMembersAdded(it.numberOfMembersAdded)) - } - } - - is GroupAddMembersResult.Failure -> internalEvents.onNext(ConversationSettingsEvent.ShowAddMembersToGroupError(it.reason)) - } - } - } - - override fun revealAllMembers() { - store.update { state -> - state.copy( - specificSettingsState = state.requireGroupSettingsState().copy( - members = state.requireGroupSettingsState().allMembers, - groupMembersExpanded = true, - canShowMoreGroupMembers = false - ) - ) - } - } - - override fun setMuteUntil(muteUntil: Long) { - repository.setMuteUntil(groupId, muteUntil) - } - - override fun unmute() { - repository.setMuteUntil(groupId, 0) - } - - override fun block() { - viewModelScope.launch { - val result = withContext(SignalDispatchers.IO) { - repository.block(groupId) - } - - if (!result.isSuccess) { - internalEvents.onNext(ConversationSettingsEvent.ShowBlockGroupError(result.getFailureReason())) - } - } - } - - override fun unblock() { - repository.unblock(groupId) - } - - private fun loadMemberLabels(v2GroupId: GroupId.V2, groupMembers: List) = viewModelScope.launch(SignalDispatchers.Default) { - val labelsByRecipientId = MemberLabelRepository.instance - .getLabels(v2GroupId, groupMembers.map { it.member }) - - store.update { state -> - state.copy( - specificSettingsState = state.requireGroupSettingsState().copy( - memberLabelsByRecipientId = labelsByRecipientId - ) - ) - } - } - - private fun loadCanSetMemberLabel(groupId: GroupId.V2) = viewModelScope.launch(SignalDispatchers.Default) { - val canSetLabel = MemberLabelRepository.instance.canSetLabel(groupId, Recipient.self()) - store.update { - it.copy( - specificSettingsState = it.requireGroupSettingsState().copy( - canSetOwnMemberLabel = canSetLabel - ) - ) - } - } - } - - class Factory( - private val recipientId: RecipientId? = null, - private val groupId: GroupId? = null, - private val callMessageIds: LongArray, - private val repository: ConversationSettingsRepository, - private val messageRequestRepository: MessageRequestRepository - ) : ViewModelProvider.Factory { - - override fun create(modelClass: Class): T { - return requireNotNull( - modelClass.cast( - when { - recipientId != null -> RecipientSettingsViewModel(recipientId, callMessageIds, repository, messageRequestRepository) - groupId != null -> GroupSettingsViewModel(groupId, callMessageIds, repository, messageRequestRepository) - else -> error("One of RecipientId or GroupId required.") - } - ) - ) - } - } - - private class DescriptionState( - val description: String?, - val canLinkify: Boolean - ) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsEvent.kt new file mode 100644 index 0000000000..5179b5d31c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsEvent.kt @@ -0,0 +1,181 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.group + +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository.GroupDetails +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallEntry +import org.thoughtcrime.securesms.database.MediaTable +import org.thoughtcrime.securesms.database.model.StoryViewState +import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * Reminder that these events are logged, so don't include anything sensitive in the toString. + */ +sealed interface GroupSettingsEvent { + + /** The user tapped the header avatar, which opens the avatar preview or the group's story. */ + data object AvatarClicked : GroupSettingsEvent + + /** The user tapped edit, which opens the group name and avatar editor. */ + data object EditGroupClicked : GroupSettingsEvent + + /** The user tapped a group description they're allowed to edit. */ + data object EditGroupDescriptionClicked : GroupSettingsEvent + + /** The user tapped a group description they can't edit, which shows it in full instead. */ + data object ViewGroupDescriptionClicked : GroupSettingsEvent + + /** The user tapped "learn more" on the legacy group notice. */ + data object LegacyGroupLearnMoreClicked : GroupSettingsEvent + + /** The user tapped the MMS group notice, which nudges them to invite the members to Signal. */ + data object LegacyGroupMmsWarningClicked : GroupSettingsEvent + + /** The user tapped the internal details button, which only internal users ever see. */ + data object InternalDetailsClicked : GroupSettingsEvent + + /** The user tapped the message button, which opens the conversation. */ + data object MessageClicked : GroupSettingsEvent + + /** The user tapped the video call button, which only admins may do in an announcement group. */ + data object VideoCallClicked : GroupSettingsEvent + + /** The user tapped add to story, which only admins may do in an announcement group. */ + data object AddToStoryClicked : GroupSettingsEvent + + /** The user tapped the mute button, which opens the mute menu or asks to confirm unmuting. */ + data object MuteClicked : GroupSettingsEvent + + /** The user picked one of the preset durations out of the mute menu. */ + data class MuteDurationSelected(val muteUntil: Long) : GroupSettingsEvent + + /** The user asked to mute until a time of their own choosing, which the fragment prompts for. */ + data object MuteUntilCustomTimeClicked : GroupSettingsEvent + + /** The user confirmed unmuting the chat. */ + data object UnmuteConfirmed : GroupSettingsEvent + + /** The user tapped the search button, which opens the conversation with search already running. */ + data object SearchClicked : GroupSettingsEvent + + /** The user tapped the disappearing messages row. */ + data object DisappearingMessagesClicked : GroupSettingsEvent + + /** The user tapped the chat color and wallpaper row. */ + data object ChatColorAndWallpaperClicked : GroupSettingsEvent + + /** The user tapped the sounds and notifications row. */ + data object SoundsAndNotificationsClicked : GroupSettingsEvent + + /** The user tapped the starred messages row. */ + data object StarredMessagesClicked : GroupSettingsEvent + + /** The user tapped an item in the shared media rail, carrying the media that item stands for. */ + data class SharedMediaClicked(val mediaRecord: MediaTable.MediaRecord, val isLtr: Boolean) : GroupSettingsEvent { + override fun toString(): String = "SharedMediaClicked(messageId=${mediaRecord.messageId}, isLtr=$isLtr)" + } + + /** The user tapped "see all" on the shared media rail, which opens the media overview. */ + data object SeeAllSharedMediaClicked : GroupSettingsEvent + + /** The user tapped the search button in the member list header. */ + data object MemberSearchClicked : GroupSettingsEvent + + /** The user tapped the row that adds members to the group. */ + data object AddMembersClicked : GroupSettingsEvent + + /** The user tapped a member row, which opens their sheet or, for themselves, the member label editor. */ + data class MemberClicked(val recipientId: RecipientId) : GroupSettingsEvent + + /** The user tapped a member's avatar, which always opens their sheet. */ + data class MemberAvatarClicked(val recipientId: RecipientId) : GroupSettingsEvent + + /** The user tapped "see all" under the collapsed member list. */ + data object RevealAllMembersClicked : GroupSettingsEvent + + /** The user tapped the group link row. */ + data object GroupLinkClicked : GroupSettingsEvent + + /** The user tapped the member label row. */ + data object GroupMemberLabelClicked : GroupSettingsEvent + + /** The user tapped the member label row while the group doesn't allow them to set one. */ + data object GroupMemberLabelDisabledClicked : GroupSettingsEvent + + /** The user tapped the requests and invites row. */ + data object RequestsAndInvitesClicked : GroupSettingsEvent + + /** The user tapped the group permissions row. */ + data object PermissionsClicked : GroupSettingsEvent + + /** The user tapped leave group, which asks the fragment to confirm first. */ + data object LeaveGroupClicked : GroupSettingsEvent + + /** The user tapped end group, which asks the fragment to confirm first. */ + data object EndGroupClicked : GroupSettingsEvent + + /** The user tapped the archive row on a group that has ended, which toggles whether the chat is archived. */ + data object ArchiveChatClicked : GroupSettingsEvent + + /** The user tapped delete chat on a group that has ended. */ + data object DeleteChatClicked : GroupSettingsEvent + + /** The user tapped block or unblock, which asks the fragment to confirm first. */ + data object BlockClicked : GroupSettingsEvent + + /** The user confirmed the block in the fragment's dialog. */ + data object BlockConfirmed : GroupSettingsEvent + + /** The user confirmed the unblock in the fragment's dialog. */ + data object UnblockConfirmed : GroupSettingsEvent + + /** The user tapped report spam, which asks the fragment to confirm first. */ + data object ReportSpamClicked : GroupSettingsEvent + + /** The user confirmed reporting spam without also blocking. */ + data object ReportSpamConfirmed : GroupSettingsEvent + + /** The user confirmed reporting spam and blocking in the same step. */ + data object BlockAndReportSpamConfirmed : GroupSettingsEvent + + /** Dismisses whatever is in [GroupSettingsState.dialog]. */ + data object DialogDismissed : GroupSettingsEvent + + /** The user picked who to add from the contact selection activity the fragment launched. */ + data class AddMembersSelected(val recipientIds: List) : GroupSettingsEvent { + override fun toString(): String = "AddMembersSelected(count=${recipientIds.size})" + } + + /** The user came back from the media viewer, so the shared media rail may be out of date. */ + data object SharedMediaRefreshRequested : GroupSettingsEvent + + /** The group's record or recipient changed, alongside whether its chat is currently archived. */ + data class GroupDetailsChanged(val details: GroupDetails, val isArchived: Boolean) : GroupSettingsEvent { + override fun toString(): String = "GroupDetailsChanged(memberCount=${details.members.size}, isArchived=$isArchived)" + } + + /** The labels for the current membership came back, alongside whether the user may set their own. */ + data class MemberLabelsLoaded(val memberLabels: Map, val canSetOwnMemberLabel: Boolean) : GroupSettingsEvent { + override fun toString(): String = "MemberLabelsLoaded(count=${memberLabels.size}, canSetOwnMemberLabel=$canSetOwnMemberLabel)" + } + + /** The group's story became unviewed, viewed, or went away entirely. */ + data class StoryViewStateChanged(val storyViewState: StoryViewState) : GroupSettingsEvent + + /** The shared media rail finished loading, either for the first time or after a refresh. */ + data class SharedMediaChanged(val media: List) : GroupSettingsEvent { + override fun toString(): String = "SharedMediaChanged(count=${media.size})" + } + + /** The calls behind the call info variant of this screen finished loading. */ + data class CallsChanged(val calls: List) : GroupSettingsEvent { + override fun toString(): String = "CallsChanged(count=${calls.size})" + } + + /** The group's thread id came back, or -1 if it doesn't have a thread yet. */ + data class ThreadIdLoaded(val threadId: Long) : GroupSettingsEvent +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsScreen.kt new file mode 100644 index 0000000000..9b9c91ed25 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsScreen.kt @@ -0,0 +1,743 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.group + +import android.view.View +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.AllDevicePreviews +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Texts +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.AvatarImage +import org.thoughtcrime.securesms.components.emoji.EmojiText +import org.thoughtcrime.securesms.components.settings.conversation.group.GroupSettingsState.Dialog +import org.thoughtcrime.securesms.components.settings.conversation.shared.ArchiveChatRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.BlockRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBar +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBarState +import org.thoughtcrime.securesms.components.settings.conversation.shared.ChatColorAndWallpaperRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.ConversationHeader +import org.thoughtcrime.securesms.components.settings.conversation.shared.ConversationSettingsScaffold +import org.thoughtcrime.securesms.components.settings.conversation.shared.DeleteChatRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.DisappearingMessagesRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.GroupMember +import org.thoughtcrime.securesms.components.settings.conversation.shared.InternalDetailsButton +import org.thoughtcrime.securesms.components.settings.conversation.shared.LargeIconRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.LegacyGroupState +import org.thoughtcrime.securesms.components.settings.conversation.shared.PREVIEW_GROUP_ID +import org.thoughtcrime.securesms.components.settings.conversation.shared.ROW_AVATAR_SIZE +import org.thoughtcrime.securesms.components.settings.conversation.shared.ReportSpamRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.SoundsAndNotificationsRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.StarredMessagesRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.UnmuteDialog +import org.thoughtcrime.securesms.components.settings.conversation.shared.callLogSection +import org.thoughtcrime.securesms.components.settings.conversation.shared.previewRecipient +import org.thoughtcrime.securesms.components.settings.conversation.shared.sharedMediaSection +import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelPill +import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.Recipient +import org.signal.core.ui.R as CoreUiR + +/** + * Settings for a group conversation. + */ +@Composable +fun GroupSettingsScreen( + state: GroupSettingsState, + onEvent: (GroupSettingsEvent) -> Unit, + onNavigationClick: () -> Unit, + onAvatarViewCreated: (View) -> Unit, + onSharedMediaViewClicked: (View) -> Unit, + modifier: Modifier = Modifier +) { + ConversationSettingsScaffold( + title = state.title, + recipient = state.recipient, + onNavigationClick = onNavigationClick, + actions = { + if (state.canEditGroupAttributes) { + IconButton(onClick = { onEvent(GroupSettingsEvent.EditGroupClicked) }) { + Icon( + painter = painterResource(CoreUiR.drawable.symbol_edit_24), + contentDescription = stringResource(R.string.ManageGroupActivity_edit_name_and_picture) + ) + } + } + }, + modifier = modifier + ) { + if (state.recipient == Recipient.UNKNOWN) { + return@ConversationSettingsScaffold + } + + item { + ConversationHeader( + recipient = state.recipient, + name = state.title, + storyViewState = state.storyViewState, + subhead = if (state.showMembershipCountAsSubhead) membershipSubhead(state) else null, + onAvatarClick = { onEvent(GroupSettingsEvent.AvatarClicked) }, + onAvatarViewCreated = onAvatarViewCreated + ) { + if (state.isTerminated) { + Text( + text = stringResource(R.string.ConversationSettingsFragment__this_group_was_ended), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .padding(top = 8.dp) + .background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(percent = 50)) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } + } + + if (state.groupId.isV2 && !state.isTerminated) { + item { + GroupDescription( + description = state.description, + canEdit = state.canEditGroupAttributes, + onEditClick = { onEvent(GroupSettingsEvent.EditGroupDescriptionClicked) }, + onViewClick = { onEvent(GroupSettingsEvent.ViewGroupDescriptionClicked) } + ) + } + } else if (state.legacyGroupState != LegacyGroupState.NONE) { + item { + LegacyGroupNotice( + legacyGroupState = state.legacyGroupState, + onLearnMoreClick = { onEvent(GroupSettingsEvent.LegacyGroupLearnMoreClicked) }, + onMmsWarningClick = { onEvent(GroupSettingsEvent.LegacyGroupMmsWarningClicked) } + ) + } + } + + if (state.displayInternalRecipientDetails) { + item { + InternalDetailsButton(onClick = { onEvent(GroupSettingsEvent.InternalDetailsClicked) }) + } + } + + item { + CallBar( + state = state.callBar, + enabled = !state.isDeprecatedOrUnregistered, + isMuteMenuShown = state.dialog == Dialog.MuteMenu, + onAddToStoryClick = { onEvent(GroupSettingsEvent.AddToStoryClicked) }, + onMessageClick = { onEvent(GroupSettingsEvent.MessageClicked) }, + onVideoCallClick = { onEvent(GroupSettingsEvent.VideoCallClicked) }, + onAudioCallClick = {}, + onMuteClick = { onEvent(GroupSettingsEvent.MuteClicked) }, + onMuteDurationSelected = { onEvent(GroupSettingsEvent.MuteDurationSelected(it)) }, + onMuteUntilCustomTimeClick = { onEvent(GroupSettingsEvent.MuteUntilCustomTimeClicked) }, + onMuteMenuDismissed = { onEvent(GroupSettingsEvent.DialogDismissed) }, + onSearchClick = { onEvent(GroupSettingsEvent.SearchClicked) } + ) + } + + item { Dividers.Default() } + + callLogSection(state.calls) + + if (!state.recipient.isBlocked) { + item { + DisappearingMessagesRow( + lifespanSeconds = state.disappearingMessagesLifespan, + enabled = state.canEditDisappearingMessages && !state.isDeprecatedOrUnregistered, + onClick = { onEvent(GroupSettingsEvent.DisappearingMessagesClicked) } + ) + } + } + + item { + ChatColorAndWallpaperRow(onClick = { onEvent(GroupSettingsEvent.ChatColorAndWallpaperClicked) }) + } + + item { + SoundsAndNotificationsRow( + isInternalUser = state.isInternalUser, + enabled = !state.isDeprecatedOrUnregistered, + onClick = { onEvent(GroupSettingsEvent.SoundsAndNotificationsClicked) } + ) + } + + if (state.starredMessagesEnabled) { + item { + StarredMessagesRow(onClick = { onEvent(GroupSettingsEvent.StarredMessagesClicked) }) + } + } + + sharedMediaSection( + media = state.sharedMedia, + loaded = state.sharedMediaLoaded, + onMediaClick = { mediaRecord, isLtr -> onEvent(GroupSettingsEvent.SharedMediaClicked(mediaRecord, isLtr)) }, + onMediaViewClicked = onSharedMediaViewClicked, + onSeeAllClick = { onEvent(GroupSettingsEvent.SeeAllSharedMediaClicked) } + ) + + membershipSection(state, onEvent) + managementSection(state, onEvent) + terminatedGroupSection(state, onEvent) + blockAndSpamSection(state, onEvent) + endGroupSection(state, onEvent) + } + + GroupSettingsDialogs( + state = state, + onEvent = onEvent + ) +} + +/** The group's member list, including the add-member and see-all affordances. */ +private fun LazyListScope.membershipSection( + state: GroupSettingsState, + onEvent: (GroupSettingsEvent) -> Unit +) { + val memberCount = state.allMembers.size + + if (state.canAddToGroup || memberCount > 0) { + item { Dividers.Default() } + + item { + MemberSectionHeader( + memberCount = memberCount, + isTerminated = state.isTerminated, + onSearchClick = { onEvent(GroupSettingsEvent.MemberSearchClicked) } + ) + } + } + + if (state.canAddMembers) { + item { + LargeIconRow( + text = stringResource(R.string.ConversationSettingsFragment__add_members), + icon = R.drawable.ic_plus_24, + onClick = { onEvent(GroupSettingsEvent.AddMembersClicked) } + ) + } + } + + items( + items = state.members, + key = { it.recipient.id.toLong() } + ) { member -> + val memberLabel = state.memberLabels[member.recipient.id] + val canSetMemberLabel = member.recipient.isSelf && state.canSetOwnMemberLabel + + MemberRow( + recipient = member.recipient, + isAdmin = member.isAdmin, + memberLabel = memberLabel, + showAddMemberLabel = canSetMemberLabel && memberLabel == null, + onClick = { onEvent(GroupSettingsEvent.MemberClicked(member.recipient.id)) }, + onAvatarClick = { onEvent(GroupSettingsEvent.MemberAvatarClicked(member.recipient.id)) } + ) + } + + if (state.canShowMoreMembers) { + item { + LargeIconRow( + text = stringResource(R.string.ConversationSettingsFragment__see_all), + icon = R.drawable.ic_chevron_down_icon_20, + onClick = { onEvent(GroupSettingsEvent.RevealAllMembersClicked) } + ) + } + } +} + +/** The group link, member labels, invite requests, permissions, and the two ways out of a group. */ +private fun LazyListScope.managementSection( + state: GroupSettingsState, + onEvent: (GroupSettingsEvent) -> Unit +) { + if (state.recipient.isPushV2Group && !state.isTerminated) { + item { Dividers.Default() } + + item { + Rows.TextRow( + text = stringResource(R.string.ConversationSettingsFragment__group_link), + label = stringResource(if (state.groupLinkEnabled) R.string.preferences_on else R.string.preferences_off), + icon = painterResource(R.drawable.ic_link_24), + enabled = state.isActive && !state.isDeprecatedOrUnregistered, + onClick = { onEvent(GroupSettingsEvent.GroupLinkClicked) } + ) + } + + item { + Rows.TextRow( + text = stringResource(R.string.ConversationSettingsFragment__group_member_label), + icon = painterResource(R.drawable.symbol_tag_24), + enabled = state.canSetOwnMemberLabel && !state.isDeprecatedOrUnregistered, + onClick = { onEvent(GroupSettingsEvent.GroupMemberLabelClicked) }, + onDisabledClick = { onEvent(GroupSettingsEvent.GroupMemberLabelDisabledClicked) } + ) + } + + item { + Rows.TextRow( + text = stringResource(R.string.ConversationSettingsFragment__requests_and_invites), + icon = painterResource(R.drawable.ic_update_group_add_16), + iconModifier = Modifier.size(24.dp), + enabled = state.isActive && !state.isDeprecatedOrUnregistered, + onClick = { onEvent(GroupSettingsEvent.RequestsAndInvitesClicked) } + ) + } + + if (state.isSelfAdmin) { + item { + Rows.TextRow( + text = stringResource(R.string.ConversationSettingsFragment__permissions), + icon = painterResource(R.drawable.ic_lock_24), + enabled = state.isActive && !state.isDeprecatedOrUnregistered, + onClick = { onEvent(GroupSettingsEvent.PermissionsClicked) } + ) + } + } + } + + if (state.canLeave) { + item { Dividers.Default() } + + item { + Rows.TextRow( + text = stringResource(R.string.conversation__menu_leave_group), + icon = painterResource(R.drawable.symbol_leave_24), + foregroundTint = MaterialTheme.colorScheme.error, + enabled = !state.isDeprecatedOrUnregistered, + onClick = { onEvent(GroupSettingsEvent.LeaveGroupClicked) } + ) + } + } +} + +/** Ending the group, which sits below block and report spam so it reads as the most drastic option. */ +private fun LazyListScope.endGroupSection( + state: GroupSettingsState, + onEvent: (GroupSettingsEvent) -> Unit +) { + if (!state.canEndGroup) { + return + } + + item { Dividers.Default() } + + item { + Rows.TextRow( + text = stringResource(R.string.ConversationSettingsFragment__end_group), + icon = painterResource(R.drawable.symbol_x_circle_24), + foregroundTint = MaterialTheme.colorScheme.error, + enabled = !state.isDeprecatedOrUnregistered, + onClick = { onEvent(GroupSettingsEvent.EndGroupClicked) } + ) + } +} + +/** Archiving and deleting, which we only offer once a group has been ended. */ +private fun LazyListScope.terminatedGroupSection( + state: GroupSettingsState, + onEvent: (GroupSettingsEvent) -> Unit +) { + if (!state.isTerminated) { + return + } + + item { Dividers.Default() } + + item { + ArchiveChatRow( + isArchived = state.isArchived, + onClick = { onEvent(GroupSettingsEvent.ArchiveChatClicked) } + ) + } + + item { + DeleteChatRow(onClick = { onEvent(GroupSettingsEvent.DeleteChatClicked) }) + } +} + +private fun LazyListScope.blockAndSpamSection( + state: GroupSettingsState, + onEvent: (GroupSettingsEvent) -> Unit +) { + if (state.isTerminated) { + item { Dividers.Default() } + + item { + ReportSpamRow(onClick = { onEvent(GroupSettingsEvent.ReportSpamClicked) }) + } + + return + } + + if (!state.canModifyBlockedState) { + return + } + + // The leave-group section already ends in a divider, so adding another here would double it up. + if (!state.canLeave) { + item { Dividers.Default() } + } + + item { + BlockRow( + isBlocked = state.recipient.isBlocked, + isGroup = true, + enabled = !state.isDeprecatedOrUnregistered, + onClick = { onEvent(GroupSettingsEvent.BlockClicked) } + ) + } + + item { + ReportSpamRow( + enabled = !state.isDeprecatedOrUnregistered, + onClick = { onEvent(GroupSettingsEvent.ReportSpamClicked) } + ) + } +} + +@Composable +private fun membershipSubhead(state: GroupSettingsState): String { + return if (state.groupId.isV1) { + stringResource(R.string.ConversationSettingsFragment__s_dot_s, state.membershipCountDescription, stringResource(R.string.ManageGroupActivity_legacy_group)) + } else { + state.membershipCountDescription + } +} + +@Composable +private fun GroupDescription( + description: String?, + canEdit: Boolean, + onEditClick: () -> Unit, + onViewClick: () -> Unit, + modifier: Modifier = Modifier +) { + if (description.isNullOrEmpty()) { + if (canEdit) { + Text( + text = stringResource(R.string.ManageGroupActivity_add_group_description), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onEditClick) + .padding(horizontal = 32.dp, vertical = 8.dp) + ) + } + } else { + EmojiText( + text = description, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 2, + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onViewClick) + .padding(horizontal = 32.dp, vertical = 8.dp) + ) + } +} + +@Composable +private fun LegacyGroupNotice( + legacyGroupState: LegacyGroupState, + onLearnMoreClick: () -> Unit, + onMmsWarningClick: () -> Unit, + modifier: Modifier = Modifier +) { + val body: String + val linkLabel: String + val onClick: () -> Unit + + when (legacyGroupState) { + LegacyGroupState.LEARN_MORE -> { + body = stringResource(R.string.ManageGroupActivity_legacy_group_learn_more) + linkLabel = stringResource(R.string.LearnMoreTextView_learn_more) + onClick = onLearnMoreClick + } + + LegacyGroupState.MMS_WARNING -> { + body = stringResource(R.string.ManageGroupActivity_this_is_an_insecure_mms_group) + linkLabel = stringResource(R.string.ManageGroupActivity_invite_now) + onClick = onMmsWarningClick + } + + LegacyGroupState.NONE -> return + } + + Column( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 32.dp, vertical = 12.dp) + ) { + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = linkLabel, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp) + ) + } +} + +@Composable +private fun MemberSectionHeader( + memberCount: Int, + isTerminated: Boolean, + onSearchClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onSearchClick) + ) { + Texts.SectionHeader( + text = if (isTerminated) { + pluralStringResource(R.plurals.ConversationSettingsFragment__d_former_members, memberCount, memberCount) + } else { + pluralStringResource(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount) + }, + modifier = Modifier.weight(1f) + ) + + Icon( + painter = painterResource(CoreUiR.drawable.symbol_search_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 24.dp) + ) + } +} + +@Composable +private fun MemberRow( + recipient: Recipient, + isAdmin: Boolean, + memberLabel: StyledMemberLabel?, + showAddMemberLabel: Boolean, + onClick: () -> Unit, + onAvatarClick: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val about = recipient.combinedAboutAndEmoji + + Rows.TextRow( + text = { + Column(modifier = Modifier.weight(1f)) { + EmojiText( + text = if (recipient.isSelf) stringResource(R.string.Recipient_you) else recipient.getDisplayName(context), + style = MaterialTheme.typography.bodyLarge + ) + + when { + memberLabel != null -> MemberLabelPill( + emoji = memberLabel.label.emoji, + text = memberLabel.label.displayText, + tintColor = Color(memberLabel.tintColor), + modifier = Modifier.padding(vertical = 2.dp), + textStyle = MemberLabelPill.textStyleCompact + ) + + showAddMemberLabel -> Text( + text = stringResource(R.string.GroupRecipientListItem__add_member_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + !about.isNullOrBlank() -> EmojiText( + text = about, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + } + + if (isAdmin) { + Text( + text = stringResource(R.string.GroupRecipientListItem_admin), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp) + ) + } + }, + icon = { + AvatarImage( + recipient = recipient, + modifier = Modifier + .size(ROW_AVATAR_SIZE) + .clickable(onClick = onAvatarClick) + ) + }, + onClick = onClick, + modifier = modifier + ) +} + +@Composable +private fun GroupSettingsDialogs( + state: GroupSettingsState, + onEvent: (GroupSettingsEvent) -> Unit +) { + when (state.dialog) { + Dialog.Unmute -> UnmuteDialog( + recipient = state.recipient, + onConfirm = { onEvent(GroupSettingsEvent.UnmuteConfirmed) }, + onDismiss = { onEvent(GroupSettingsEvent.DialogDismissed) } + ) + + Dialog.CannotAddToGroupStory -> Dialogs.SimpleMessageDialog( + title = stringResource(R.string.ConversationSettingsFragment__cant_add_to_group_story), + message = stringResource(R.string.ConversationSettingsFragment__only_admins_of_this_group_can_add_to_its_story), + dismiss = stringResource(android.R.string.ok), + onDismiss = { onEvent(GroupSettingsEvent.DialogDismissed) } + ) + + Dialog.CannotStartGroupCall -> Dialogs.SimpleMessageDialog( + title = stringResource(R.string.ConversationActivity_cant_start_group_call), + message = stringResource(R.string.ConversationActivity_only_admins_of_this_group_can_start_a_call), + dismiss = stringResource(android.R.string.ok), + onDismiss = { onEvent(GroupSettingsEvent.DialogDismissed) } + ) + + Dialog.DeletingChat -> Dialogs.IndeterminateProgressDialog(message = stringResource(R.string.ConversationFragment_deleting_messages)) + + Dialog.AddingMembers -> Dialogs.IndeterminateProgressDialog() + + // The mute menu is a dropdown anchored to the call bar, so CallBar renders it rather than us. + Dialog.MuteMenu, Dialog.None -> Unit + } +} + +private val PREVIEW_MEMBERS = listOf( + GroupMember(previewRecipient(2L, profileName = ProfileName.fromParts("Benjamin", "Sisko")), isAdmin = true), + GroupMember(previewRecipient(3L, profileName = ProfileName.fromParts("Jadzia", "Dax")), isAdmin = false) +) + +private fun previewState( + recipient: Recipient = previewRecipient(1L, groupName = "Deep Space Nine", groupId = PREVIEW_GROUP_ID), + isActive: Boolean = true, + isTerminated: Boolean = false, + legacyGroupState: LegacyGroupState = LegacyGroupState.NONE, + membersExpanded: Boolean = false, + allMembers: List = PREVIEW_MEMBERS +): GroupSettingsState { + return GroupSettingsState( + groupId = PREVIEW_GROUP_ID, + recipient = recipient, + threadId = 1L, + sharedMediaLoaded = true, + canModifyBlockedState = true, + allMembers = allMembers, + membersExpanded = membersExpanded, + title = "Deep Space Nine", + description = "Bajoran space station", + membershipCountDescription = "${allMembers.size} members", + canEditGroupAttributes = true, + canAddToGroup = true, + isActive = isActive, + isTerminated = isTerminated, + legacyGroupState = legacyGroupState, + isSelfAdmin = true, + detailsLoaded = true, + callBar = CallBarState( + isVideoAvailable = true, + isMuteAvailable = true, + isSearchAvailable = true, + isAddToStoryAvailable = true + ) + ) +} + +@Composable +private fun GroupSettingsScreenPreview(state: GroupSettingsState) { + Previews.Preview { + GroupSettingsScreen( + state = state, + onEvent = {}, + onNavigationClick = {}, + onAvatarViewCreated = {}, + onSharedMediaViewClicked = {} + ) + } +} + +@AllDevicePreviews +@Composable +private fun GroupSettingsScreenActivePreview() { + GroupSettingsScreenPreview(previewState()) +} + +@DayNightPreviews +@Composable +private fun GroupSettingsScreenTerminatedPreview() { + GroupSettingsScreenPreview(previewState(isActive = false, isTerminated = true)) +} + +@DayNightPreviews +@Composable +private fun GroupSettingsScreenLegacyPreview() { + GroupSettingsScreenPreview(previewState(legacyGroupState = LegacyGroupState.LEARN_MORE)) +} + +@DayNightPreviews +@Composable +private fun GroupSettingsScreenMmsPreview() { + GroupSettingsScreenPreview(previewState(legacyGroupState = LegacyGroupState.MMS_WARNING)) +} + +@DayNightPreviews +@Composable +private fun GroupSettingsScreenBlockedPreview() { + GroupSettingsScreenPreview(previewState(recipient = previewRecipient(1L, groupName = "Deep Space Nine", groupId = PREVIEW_GROUP_ID, isBlocked = true))) +} + +/** Seven members is one past the collapse threshold, so this shows the "see all" affordance. */ +@DayNightPreviews +@Composable +private fun GroupSettingsScreenCollapsedMembersPreview() { + val members = (2L..8L).map { GroupMember(previewRecipient(it, profileName = ProfileName.fromParts("Member", "$it")), isAdmin = false) } + GroupSettingsScreenPreview(previewState(allMembers = members)) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsState.kt new file mode 100644 index 0000000000..8cfe234c14 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsState.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.group + +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBarState +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallEntry +import org.thoughtcrime.securesms.components.settings.conversation.shared.CollapsibleList +import org.thoughtcrime.securesms.components.settings.conversation.shared.GroupMember +import org.thoughtcrime.securesms.components.settings.conversation.shared.LegacyGroupState +import org.thoughtcrime.securesms.database.MediaTable +import org.thoughtcrime.securesms.database.model.StoryViewState +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +data class GroupSettingsState( + val groupId: GroupId, + val recipient: Recipient = Recipient.UNKNOWN, + val threadId: Long = -1L, + val storyViewState: StoryViewState = StoryViewState.NONE, + val isDeprecatedOrUnregistered: Boolean = false, + val isInternalUser: Boolean = false, + val displayInternalRecipientDetails: Boolean = false, + val starredMessagesEnabled: Boolean = false, + val disappearingMessagesLifespan: Int = 0, + val canModifyBlockedState: Boolean = false, + val isArchived: Boolean = false, + val sharedMedia: List = emptyList(), + val sharedMediaLoaded: Boolean = false, + val calls: List = emptyList(), + val callBar: CallBarState = CallBarState(), + val title: String = "", + val description: String? = null, + val descriptionShouldLinkify: Boolean = false, + val membershipCountDescription: String = "", + val allMembers: List = emptyList(), + val membersExpanded: Boolean = false, + val memberLabels: Map = emptyMap(), + val canSetOwnMemberLabel: Boolean = false, + val isSelfAdmin: Boolean = false, + val canAddToGroup: Boolean = false, + val canEditGroupAttributes: Boolean = false, + val isActive: Boolean = false, + val isTerminated: Boolean = false, + val isAnnouncementGroup: Boolean = false, + val groupLinkEnabled: Boolean = false, + val legacyGroupState: LegacyGroupState = LegacyGroupState.NONE, + val detailsLoaded: Boolean = false, + val dialog: Dialog = Dialog.None +) { + + /** + * True once we've loaded enough to render the screen without it visibly shuffling around. Shared media is + * deliberately not part of this: the rail reserves its space while loading, so there's no reason to hold the whole + * screen behind it. + */ + val isLoaded: Boolean = recipient != Recipient.UNKNOWN && detailsLoaded + + val members: List = CollapsibleList.collapse(allMembers, membersExpanded) + + val canShowMoreMembers: Boolean = CollapsibleList.canExpand(allMembers, membersExpanded) + + /** Disappearing messages can only be changed by those who can edit group attributes, and never on a blocked chat. */ + val canEditDisappearingMessages: Boolean = canEditGroupAttributes && !recipient.isBlocked + + val canLeave: Boolean = isActive && groupId.isPush + + val canEndGroup: Boolean = isActive && groupId.isV2 && isSelfAdmin + + val canAddMembers: Boolean = canAddToGroup && !isTerminated && !isDeprecatedOrUnregistered + + /** Only group admins can add to an announcement group's story or start a call in one. */ + val isAnnouncementGroupRestricted: Boolean = isAnnouncementGroup && !isSelfAdmin + + /** + * Whether the line under the group name should be the member count. V2 groups normally put their description there + * instead. + */ + val showMembershipCountAsSubhead: Boolean = groupId.isV1 || (!canEditGroupAttributes && description.isNullOrEmpty()) + + sealed interface Dialog { + data object None : Dialog + data object MuteMenu : Dialog + data object Unmute : Dialog + data object CannotAddToGroupStory : Dialog + data object CannotStartGroupCall : Dialog + data object DeletingChat : Dialog + data object AddingMembers : Dialog + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsViewModel.kt new file mode 100644 index 0000000000..1432aaf00d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsViewModel.kt @@ -0,0 +1,443 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.group + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.signal.core.ui.compose.EventDrivenViewModel +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsAction +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository.GroupDetails +import org.thoughtcrime.securesms.components.settings.conversation.group.GroupSettingsState.Dialog +import org.thoughtcrime.securesms.components.settings.conversation.shared.BlockAndSpamHandler +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBarState +import org.thoughtcrime.securesms.components.settings.conversation.shared.SharedMediaLoader +import org.thoughtcrime.securesms.components.settings.conversation.shared.sharedMediaClickAction +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.SelectionLimits +import org.thoughtcrime.securesms.groups.v2.GroupAddMembersResult +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * View model behind [GroupSettingsScreen]. + */ +class GroupSettingsViewModel( + private val groupId: GroupId, + private val callMessageIds: LongArray, + private val repository: ConversationSettingsRepository +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(GroupSettingsViewModel::class) + } + + /** Whether we're the call-info variant of this screen, which shows a message button in place of search. */ + private val isCallInfoVariant: Boolean = callMessageIds.isNotEmpty() + + private val _state = MutableStateFlow( + GroupSettingsState( + groupId = groupId, + isDeprecatedOrUnregistered = repository.isDeprecatedOrUnregistered(), + starredMessagesEnabled = repository.isStarredMessagesEnabled(), + isInternalUser = repository.isInternalUser(), + displayInternalRecipientDetails = repository.isInternalRecipientDetailsEnabled() + ) + ) + + private val _actions = Channel(Channel.BUFFERED) + + val state: StateFlow = _state.asStateFlow() + val actions: Flow = _actions.receiveAsFlow() + + private val sharedMediaLoader = SharedMediaLoader(repository) + + init { + repository + .observeGroupDetails(groupId) + .onEach { details -> + onEvent(GroupSettingsEvent.GroupDetailsChanged(details, repository.isArchived(details.recipient.id))) + loadMemberLabels(details.members.map { member -> member.recipient }) + } + .launchIn(viewModelScope) + + repository + .observeStoryViewState(groupId) + .onEach { onEvent(GroupSettingsEvent.StoryViewStateChanged(it)) } + .launchIn(viewModelScope) + + sharedMediaLoader + .observe() + .onEach { onEvent(GroupSettingsEvent.SharedMediaChanged(it)) } + .launchIn(viewModelScope) + + if (callMessageIds.isNotEmpty()) { + repository + .observeCalls(_state.map { it.threadId }, callMessageIds) + .onEach { onEvent(GroupSettingsEvent.CallsChanged(it)) } + .launchIn(viewModelScope) + } + + viewModelScope.launch { + onEvent(GroupSettingsEvent.ThreadIdLoaded(repository.getThreadId(groupId))) + } + } + + override suspend fun processEvent(event: GroupSettingsEvent) { + val state = _state.value + + when (event) { + GroupSettingsEvent.AvatarClicked -> { + BlockAndSpamHandler.avatarClickAction(state.recipient, state.storyViewState, repository.isStoriesFeatureEnabled())?.let { _actions.send(it) } + } + + GroupSettingsEvent.EditGroupClicked -> { + _actions.send(ConversationSettingsAction.EditGroupProfile(groupId)) + } + + GroupSettingsEvent.EditGroupDescriptionClicked -> { + _actions.send(ConversationSettingsAction.EditGroupDescription(groupId)) + } + + GroupSettingsEvent.ViewGroupDescriptionClicked -> { + _actions.send(ConversationSettingsAction.ShowGroupDescriptionDialog(groupId, state.descriptionShouldLinkify)) + } + + GroupSettingsEvent.LegacyGroupLearnMoreClicked -> { + _actions.send(ConversationSettingsAction.ShowGroupsLearnMore) + } + + GroupSettingsEvent.LegacyGroupMmsWarningClicked -> { + _actions.send(ConversationSettingsAction.ShowInviteFriends) + } + + GroupSettingsEvent.InternalDetailsClicked -> { + _actions.send(ConversationSettingsAction.NavigateToInternalDetails(state.recipient.id)) + } + + GroupSettingsEvent.MessageClicked -> { + _actions.send(ConversationSettingsAction.OpenConversation(state.recipient.id, state.threadId)) + } + + GroupSettingsEvent.VideoCallClicked -> { + if (state.isAnnouncementGroupRestricted) { + _state.update { it.copy(dialog = Dialog.CannotStartGroupCall) } + } else { + _actions.send(ConversationSettingsAction.StartVideoCall(state.recipient)) + } + } + + GroupSettingsEvent.AddToStoryClicked -> { + if (state.isAnnouncementGroupRestricted) { + _state.update { it.copy(dialog = Dialog.CannotAddToGroupStory) } + } else { + _actions.send(ConversationSettingsAction.AddToGroupStory(state.recipient.id)) + } + } + + GroupSettingsEvent.MuteClicked -> { + _state.update { it.copy(dialog = if (state.callBar.isMuted) Dialog.Unmute else Dialog.MuteMenu) } + } + + is GroupSettingsEvent.MuteDurationSelected -> { + _state.update { it.copy(dialog = Dialog.None) } + repository.setMuteUntil(groupId, event.muteUntil) + } + + GroupSettingsEvent.MuteUntilCustomTimeClicked -> { + _state.update { it.copy(dialog = Dialog.None) } + _actions.send(ConversationSettingsAction.ShowMuteUntilTimePicker) + } + + GroupSettingsEvent.UnmuteConfirmed -> { + _state.update { it.copy(dialog = Dialog.None) } + repository.setMuteUntil(groupId, 0) + } + + GroupSettingsEvent.SearchClicked -> { + _actions.send(ConversationSettingsAction.OpenConversation(state.recipient.id, state.threadId, withSearchOpen = true)) + } + + GroupSettingsEvent.DisappearingMessagesClicked -> { + _actions.send(ConversationSettingsAction.NavigateToDisappearingMessages(state.recipient.id, state.disappearingMessagesLifespan)) + } + + GroupSettingsEvent.ChatColorAndWallpaperClicked -> _actions.send(ConversationSettingsAction.OpenChatWallpaper(state.recipient.id)) + + GroupSettingsEvent.SoundsAndNotificationsClicked -> { + _actions.send(ConversationSettingsAction.NavigateToSoundsAndNotifications(state.recipient.id, state.isInternalUser)) + } + + GroupSettingsEvent.StarredMessagesClicked -> _actions.send(ConversationSettingsAction.OpenStarredMessages(state.threadId)) + + is GroupSettingsEvent.SharedMediaClicked -> _actions.send(sharedMediaClickAction(event.mediaRecord, event.isLtr)) + + GroupSettingsEvent.SeeAllSharedMediaClicked -> _actions.send(ConversationSettingsAction.ShowMediaOverview(state.threadId)) + + GroupSettingsEvent.MemberSearchClicked -> { + _actions.send(ConversationSettingsAction.NavigateToMemberSearch(groupId, state.canAddMembers, state.groupLinkEnabled)) + } + + GroupSettingsEvent.AddMembersClicked -> applyAddMembersClicked() + + is GroupSettingsEvent.AddMembersSelected -> applyAddMembersSelected(event) + + is GroupSettingsEvent.MemberClicked -> { + val member = state.allMembers.firstOrNull { it.recipient.id == event.recipientId } + val canSetMemberLabel = member?.recipient?.isSelf == true && state.canSetOwnMemberLabel + + if (canSetMemberLabel && state.memberLabels[event.recipientId] == null) { + _actions.send(ConversationSettingsAction.NavigateToMemberLabel(groupId)) + } else { + _actions.send(ConversationSettingsAction.ShowRecipientBottomSheet(event.recipientId, groupId)) + } + } + + is GroupSettingsEvent.MemberAvatarClicked -> { + _actions.send(ConversationSettingsAction.ShowRecipientBottomSheet(event.recipientId, groupId)) + } + + GroupSettingsEvent.RevealAllMembersClicked -> { + _state.update { it.copy(membersExpanded = true) } + } + + GroupSettingsEvent.GroupLinkClicked -> { + _actions.send(ConversationSettingsAction.NavigateToShareableGroupLink(groupId)) + } + + GroupSettingsEvent.GroupMemberLabelClicked -> { + _actions.send(ConversationSettingsAction.NavigateToMemberLabel(groupId)) + } + + GroupSettingsEvent.GroupMemberLabelDisabledClicked -> { + _actions.send(ConversationSettingsAction.ShowMemberLabelPermissionError) + } + + GroupSettingsEvent.RequestsAndInvitesClicked -> { + _actions.send(ConversationSettingsAction.OpenRequestsAndInvites(groupId.requireV2())) + } + + GroupSettingsEvent.PermissionsClicked -> { + _actions.send(ConversationSettingsAction.NavigateToPermissions(groupId)) + } + + GroupSettingsEvent.LeaveGroupClicked -> { + _actions.send(ConversationSettingsAction.ShowLeaveGroupDialog(groupId)) + } + + GroupSettingsEvent.EndGroupClicked -> { + _actions.send(ConversationSettingsAction.ShowEndGroupDialog(groupId.requireV2(), state.title)) + } + + GroupSettingsEvent.ArchiveChatClicked -> { + applyArchiveToggle(state) + } + + GroupSettingsEvent.DeleteChatClicked -> { + applyDeleteChat(state) + } + + GroupSettingsEvent.BlockClicked -> { + _actions.send(BlockAndSpamHandler.blockAction(state.recipient)) + } + + GroupSettingsEvent.BlockConfirmed -> { + val result = repository.block(groupId) + if (!result.isSuccess) { + _actions.send(ConversationSettingsAction.ShowBlockError(result.getFailureReason())) + } + } + + GroupSettingsEvent.UnblockConfirmed -> { + repository.unblock(groupId) + } + + GroupSettingsEvent.ReportSpamClicked -> { + _actions.send(BlockAndSpamHandler.reportSpamAction(state.recipient)) + } + + GroupSettingsEvent.ReportSpamConfirmed -> { + BlockAndSpamHandler.reportSpam(state.recipient, state.threadId, repository) { _actions.send(it) } + } + + GroupSettingsEvent.BlockAndReportSpamConfirmed -> { + BlockAndSpamHandler.blockAndReportSpam(state.recipient, state.threadId, repository) { _actions.send(it) } + } + + GroupSettingsEvent.DialogDismissed -> { + _state.update { it.copy(dialog = Dialog.None) } + } + + GroupSettingsEvent.SharedMediaRefreshRequested -> { + sharedMediaLoader.refresh() + } + + is GroupSettingsEvent.GroupDetailsChanged -> { + _state.update { it.applyGroupDetails(event.details, event.isArchived) } + } + + is GroupSettingsEvent.MemberLabelsLoaded -> { + _state.update { + it.copy( + memberLabels = event.memberLabels, + canSetOwnMemberLabel = event.canSetOwnMemberLabel + ) + } + } + + is GroupSettingsEvent.StoryViewStateChanged -> { + _state.update { it.copy(storyViewState = event.storyViewState) } + } + + is GroupSettingsEvent.SharedMediaChanged -> { + _state.update { it.copy(sharedMedia = event.media, sharedMediaLoaded = true) } + } + + is GroupSettingsEvent.CallsChanged -> { + _state.update { it.copy(calls = event.calls) } + } + + is GroupSettingsEvent.ThreadIdLoaded -> { + _state.update { it.copy(threadId = event.threadId) } + sharedMediaLoader.onThreadIdLoaded(event.threadId) + } + } + } + + private suspend fun applyAddMembersClicked() { + val capacity = repository.getGroupCapacity(groupId) + if (capacity == null) { + Log.w(TAG, "No group record to read capacity from, ignoring.") + return + } + + if (capacity.getRemainingCapacity() > 0) { + _actions.send( + ConversationSettingsAction.AddMembersToGroup( + groupId = groupId, + selectionLimits = SelectionLimits(capacity.getSelectionWarning(), capacity.getSelectionLimit()), + groupMembersWithoutSelf = capacity.getMembersWithoutSelf() + ) + ) + } else { + _actions.send(ConversationSettingsAction.ShowGroupHardLimitDialog) + } + } + + private suspend fun applyAddMembersSelected(event: GroupSettingsEvent.AddMembersSelected) { + _state.update { it.copy(dialog = Dialog.AddingMembers) } + val result = repository.addMembers(groupId, event.recipientIds) + _state.update { it.copy(dialog = Dialog.None) } + + when (result) { + is GroupAddMembersResult.Success -> { + if (result.newMembersInvited.isNotEmpty()) { + _actions.send(ConversationSettingsAction.ShowGroupInvitesSentDialog(result.newMembersInvited)) + } + + if (result.numberOfMembersAdded > 0) { + _actions.send(ConversationSettingsAction.ShowMembersAdded(result.numberOfMembersAdded)) + } + } + + is GroupAddMembersResult.Failure -> _actions.send(ConversationSettingsAction.ShowAddMembersError(result.reason)) + } + } + + private suspend fun applyArchiveToggle(state: GroupSettingsState) { + if (state.threadId <= 0) { + return + } + + val archived = !state.isArchived + _state.update { it.copy(isArchived = archived) } + repository.setArchived(state.threadId, archived) + + if (archived) { + _actions.send(ConversationSettingsAction.GoToConversationList) + } + } + + private suspend fun applyDeleteChat(state: GroupSettingsState) { + if (state.threadId <= 0) { + return + } + + _state.update { it.copy(dialog = Dialog.DeletingChat) } + repository.deleteChat(state.threadId) + _state.update { it.copy(dialog = Dialog.None) } + _actions.send(ConversationSettingsAction.GoToConversationList) + } + + private suspend fun loadMemberLabels(members: List) { + val v2GroupId = groupId.v2OrNull() ?: return + + val memberLabels = repository.getMemberLabels(v2GroupId, members) + val canSetOwnMemberLabel = repository.canSetOwnMemberLabel(v2GroupId) + + onEvent(GroupSettingsEvent.MemberLabelsLoaded(memberLabels, canSetOwnMemberLabel)) + } + + private fun GroupSettingsState.applyGroupDetails( + details: GroupDetails, + isArchived: Boolean + ): GroupSettingsState { + val recipient = details.recipient + + return copy( + recipient = recipient, + callBar = CallBarState( + isMessageAvailable = isCallInfoVariant, + isVideoAvailable = recipient.isPushV2Group && !recipient.isBlocked && recipient.isActiveGroup, + isAudioAvailable = false, + isAudioSecure = recipient.isPushV2Group, + isMuteAvailable = true, + isMuted = recipient.isMuted, + isSearchAvailable = !isCallInfoVariant, + isAddToStoryAvailable = recipient.isPushV2Group && !recipient.isBlocked && recipient.isActiveGroup && repository.isAddToStoryAvailable() + ), + disappearingMessagesLifespan = recipient.expiresInSeconds, + canModifyBlockedState = repository.isBlockable(recipient), + isArchived = isArchived, + allMembers = details.members, + isSelfAdmin = details.isSelfAdmin, + canAddToGroup = details.canAddMembers, + canEditGroupAttributes = details.canEditGroupAttributes, + isActive = details.isActive, + isTerminated = details.isTerminated, + title = details.title, + description = details.description, + descriptionShouldLinkify = details.descriptionShouldLinkify, + groupLinkEnabled = details.groupLinkEnabled, + membershipCountDescription = details.membershipCountDescription, + legacyGroupState = details.legacyGroupState, + isAnnouncementGroup = details.isAnnouncementGroup, + detailsLoaded = true + ) + } + + class Factory( + private val groupId: GroupId, + private val callMessageIds: LongArray, + private val repository: ConversationSettingsRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return requireNotNull(modelClass.cast(GroupSettingsViewModel(groupId, callMessageIds, repository))) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsEvent.kt new file mode 100644 index 0000000000..ffc200b154 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsEvent.kt @@ -0,0 +1,169 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.individual + +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallEntry +import org.thoughtcrime.securesms.database.MediaTable +import org.thoughtcrime.securesms.database.model.IdentityRecord +import org.thoughtcrime.securesms.database.model.StoryViewState +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * Reminder that these events are logged, so don't include anything sensitive in the toString. + */ +sealed interface IndividualSettingsEvent { + + /** The user tapped the header avatar, which opens the avatar preview or the recipient's story. */ + data object AvatarClicked : IndividualSettingsEvent + + /** The user tapped one of the badges shown under the recipient's name. */ + data class BadgeClicked(val badge: Badge) : IndividualSettingsEvent { + override fun toString(): String = "BadgeClicked" + } + + /** The user tapped the recipient's name, which opens the about sheet. */ + data object HeadlineClicked : IndividualSettingsEvent + + /** The user tapped the internal details button, which only internal users ever see. */ + data object InternalDetailsClicked : IndividualSettingsEvent + + /** The user tapped the message button, which opens the conversation. */ + data object MessageClicked : IndividualSettingsEvent + + /** The user tapped the video call button. */ + data object VideoCallClicked : IndividualSettingsEvent + + /** The user tapped the audio call button. */ + data object AudioCallClicked : IndividualSettingsEvent + + /** The user tapped the mute button, which opens the mute menu or asks to confirm unmuting. */ + data object MuteClicked : IndividualSettingsEvent + + /** The user picked one of the preset durations out of the mute menu. */ + data class MuteDurationSelected(val muteUntil: Long) : IndividualSettingsEvent + + /** The user asked to mute until a time of their own choosing, which the fragment prompts for. */ + data object MuteUntilCustomTimeClicked : IndividualSettingsEvent + + /** The user confirmed unmuting the chat. */ + data object UnmuteConfirmed : IndividualSettingsEvent + + /** The user tapped the search button, which opens the conversation with search already running. */ + data object SearchClicked : IndividualSettingsEvent + + /** The user tapped the disappearing messages row. */ + data object DisappearingMessagesClicked : IndividualSettingsEvent + + /** The user tapped the nickname row. */ + data object NicknameClicked : IndividualSettingsEvent + + /** The user tapped the chat color and wallpaper row. */ + data object ChatColorAndWallpaperClicked : IndividualSettingsEvent + + /** The user tapped the sounds and notifications row. */ + data object SoundsAndNotificationsClicked : IndividualSettingsEvent + + /** The user tapped the starred messages row. */ + data object StarredMessagesClicked : IndividualSettingsEvent + + /** The user tapped the row that opens the recipient's entry in the system contacts. */ + data object ContactDetailsClicked : IndividualSettingsEvent + + /** The user tapped the row that adds the recipient to the system contacts. */ + data object AddAsContactClicked : IndividualSettingsEvent + + /** The user tapped the safety number row. */ + data object ViewSafetyNumberClicked : IndividualSettingsEvent + + /** The user tapped an item in the shared media rail, carrying the media that item stands for. */ + data class SharedMediaClicked(val mediaRecord: MediaTable.MediaRecord, val isLtr: Boolean) : IndividualSettingsEvent { + override fun toString(): String = "SharedMediaClicked(messageId=${mediaRecord.messageId}, isLtr=$isLtr)" + } + + /** The user tapped "see all" on the shared media rail, which opens the media overview. */ + data object SeeAllSharedMediaClicked : IndividualSettingsEvent + + /** The user tapped the support center link, which only the release notes chat offers. */ + data object SupportCenterClicked : IndividualSettingsEvent + + /** The user tapped the contact us link, which only the release notes chat offers. */ + data object ContactUsClicked : IndividualSettingsEvent + + /** The user tapped the donate link, which only the release notes chat offers. */ + data object DonateClicked : IndividualSettingsEvent + + /** The user tapped the row that adds this recipient to one of their groups. */ + data object AddToAGroupClicked : IndividualSettingsEvent + + /** The user tapped one of the groups they have in common with the recipient. */ + data class GroupInCommonClicked(val recipientId: RecipientId) : IndividualSettingsEvent + + /** The user tapped "see all" under the collapsed list of groups in common. */ + data object RevealAllGroupsInCommonClicked : IndividualSettingsEvent + + /** The user tapped block or unblock, which asks the fragment to confirm first. */ + data object BlockClicked : IndividualSettingsEvent + + /** The user confirmed the block in the fragment's dialog. */ + data object BlockConfirmed : IndividualSettingsEvent + + /** The user confirmed the unblock in the fragment's dialog. */ + data object UnblockConfirmed : IndividualSettingsEvent + + /** The user tapped report spam, which asks the fragment to confirm first. */ + data object ReportSpamClicked : IndividualSettingsEvent + + /** The user confirmed reporting spam without also blocking. */ + data object ReportSpamConfirmed : IndividualSettingsEvent + + /** The user confirmed reporting spam and blocking in the same step. */ + data object BlockAndReportSpamConfirmed : IndividualSettingsEvent + + /** Dismisses whatever is in [IndividualSettingsState.dialog]. */ + data object DialogDismissed : IndividualSettingsEvent + + /** The user came back from the media viewer, so the shared media rail may be out of date. */ + data object SharedMediaRefreshRequested : IndividualSettingsEvent + + /** The user came back from adding or viewing a system contact, so the recipient may be out of date. */ + data object RecipientRefreshRequested : IndividualSettingsEvent + + /** The recipient this screen is about changed. */ + data class RecipientChanged(val recipient: Recipient) : IndividualSettingsEvent { + override fun toString(): String = "RecipientChanged(${recipient.id})" + } + + /** The recipient's story became unviewed, viewed, or went away entirely. */ + data class StoryViewStateChanged(val storyViewState: StoryViewState) : IndividualSettingsEvent + + /** The shared media rail finished loading, either for the first time or after a refresh. */ + data class SharedMediaChanged(val media: List) : IndividualSettingsEvent { + override fun toString(): String = "SharedMediaChanged(count=${media.size})" + } + + /** The calls behind the call info variant of this screen finished loading. */ + data class CallsChanged(val calls: List) : IndividualSettingsEvent { + override fun toString(): String = "CallsChanged(count=${calls.size})" + } + + /** The recipient's thread id came back, or -1 if they don't have a thread yet. */ + data class ThreadIdLoaded(val threadId: Long) : IndividualSettingsEvent + + /** The groups the user and this recipient are both in changed. */ + data class GroupsInCommonChanged(val groupsInCommon: List) : IndividualSettingsEvent { + override fun toString(): String = "GroupsInCommonChanged(count=${groupsInCommon.size})" + } + + /** Whether the user is in any groups at all came back, which decides if we can offer to add this recipient to one. */ + data class SelfHasGroupsLoaded(val selfHasGroups: Boolean) : IndividualSettingsEvent + + /** The recipient's identity record came back, which the safety number row needs. */ + data class IdentityRecordLoaded(val identityRecord: IdentityRecord?) : IndividualSettingsEvent { + override fun toString(): String = "IdentityRecordLoaded(present=${identityRecord != null})" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsScreen.kt new file mode 100644 index 0000000000..2e66b028c1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsScreen.kt @@ -0,0 +1,382 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.individual + +import android.view.View +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import org.signal.core.ui.compose.AllDevicePreviews +import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Texts +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.badges.BadgeImageView +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.components.settings.conversation.individual.IndividualSettingsState.Dialog +import org.thoughtcrime.securesms.components.settings.conversation.shared.BlockRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBar +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBarState +import org.thoughtcrime.securesms.components.settings.conversation.shared.ChatColorAndWallpaperRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.ConversationHeader +import org.thoughtcrime.securesms.components.settings.conversation.shared.ConversationSettingsScaffold +import org.thoughtcrime.securesms.components.settings.conversation.shared.DisappearingMessagesRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.InternalDetailsButton +import org.thoughtcrime.securesms.components.settings.conversation.shared.LargeIconRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.RecipientRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.ReportSpamRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.SoundsAndNotificationsRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.StarredMessagesRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.UnmuteDialog +import org.thoughtcrime.securesms.components.settings.conversation.shared.callLogSection +import org.thoughtcrime.securesms.components.settings.conversation.shared.previewRecipient +import org.thoughtcrime.securesms.components.settings.conversation.shared.sharedMediaSection +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.Recipient +import org.signal.core.ui.R as CoreUiR + +private val BADGE_SIZE = 64.dp + +/** + * Settings for a 1:1 conversation with another person. + */ +@Composable +fun IndividualSettingsScreen( + state: IndividualSettingsState, + onEvent: (IndividualSettingsEvent) -> Unit, + onNavigationClick: () -> Unit, + onAvatarViewCreated: (View) -> Unit, + onSharedMediaViewClicked: (View) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + ConversationSettingsScaffold( + title = state.recipient.getDisplayName(context), + recipient = state.recipient, + onNavigationClick = onNavigationClick, + modifier = modifier + ) { + if (state.recipient == Recipient.UNKNOWN) { + return@ConversationSettingsScaffold + } + + item { + ConversationHeader( + recipient = state.recipient, + name = state.recipient.getDisplayName(context), + storyViewState = state.storyViewState, + subhead = state.recipient.combinedAboutAndEmoji, + showVerifiedBadge = state.recipient.showVerified, + showSystemContactBadge = state.recipient.isSystemContact, + badges = state.recipient.badges, + onAvatarClick = { onEvent(IndividualSettingsEvent.AvatarClicked) }, + onBadgeClick = { onEvent(IndividualSettingsEvent.BadgeClicked(it)) }, + onNameClick = { onEvent(IndividualSettingsEvent.HeadlineClicked) }, + onAvatarViewCreated = onAvatarViewCreated + ) + } + + if (state.displayInternalRecipientDetails) { + item { + InternalDetailsButton(onClick = { onEvent(IndividualSettingsEvent.InternalDetailsClicked) }) + } + } + + item { + CallBar( + state = state.callBar, + enabled = !state.isDeprecatedOrUnregistered, + isMuteMenuShown = state.dialog == Dialog.MuteMenu, + onAddToStoryClick = {}, + onMessageClick = { onEvent(IndividualSettingsEvent.MessageClicked) }, + onVideoCallClick = { onEvent(IndividualSettingsEvent.VideoCallClicked) }, + onAudioCallClick = { onEvent(IndividualSettingsEvent.AudioCallClicked) }, + onMuteClick = { onEvent(IndividualSettingsEvent.MuteClicked) }, + onMuteDurationSelected = { onEvent(IndividualSettingsEvent.MuteDurationSelected(it)) }, + onMuteUntilCustomTimeClick = { onEvent(IndividualSettingsEvent.MuteUntilCustomTimeClicked) }, + onMuteMenuDismissed = { onEvent(IndividualSettingsEvent.DialogDismissed) }, + onSearchClick = { onEvent(IndividualSettingsEvent.SearchClicked) } + ) + } + + item { Dividers.Default() } + + callLogSection(state.calls) + + if (!state.recipient.isBlocked) { + item { + DisappearingMessagesRow( + lifespanSeconds = state.disappearingMessagesLifespan, + enabled = !state.isDeprecatedOrUnregistered, + onClick = { onEvent(IndividualSettingsEvent.DisappearingMessagesClicked) } + ) + } + } + + item { + Rows.TextRow( + text = stringResource(R.string.NicknameActivity__nickname), + icon = painterResource(CoreUiR.drawable.symbol_edit_24), + onClick = { onEvent(IndividualSettingsEvent.NicknameClicked) } + ) + } + + item { + ChatColorAndWallpaperRow(onClick = { onEvent(IndividualSettingsEvent.ChatColorAndWallpaperClicked) }) + } + + item { + SoundsAndNotificationsRow( + isInternalUser = state.isInternalUser, + enabled = !state.isDeprecatedOrUnregistered, + onClick = { onEvent(IndividualSettingsEvent.SoundsAndNotificationsClicked) } + ) + } + + if (state.starredMessagesEnabled) { + item { + StarredMessagesRow(onClick = { onEvent(IndividualSettingsEvent.StarredMessagesClicked) }) + } + } + + when (state.contactLinkState) { + ContactLinkState.OPEN -> item { + Rows.TextRow( + text = stringResource(R.string.ConversationSettingsFragment__contact_details), + icon = painterResource(R.drawable.ic_profile_circle_24), + onClick = { onEvent(IndividualSettingsEvent.ContactDetailsClicked) } + ) + } + + ContactLinkState.ADD -> item { + Rows.TextRow( + text = stringResource(R.string.ConversationSettingsFragment__add_as_a_contact), + icon = painterResource(R.drawable.ic_plus_24), + onClick = { onEvent(IndividualSettingsEvent.AddAsContactClicked) } + ) + } + + ContactLinkState.NONE -> Unit + } + + item { + Rows.TextRow( + text = stringResource(R.string.ConversationSettingsFragment__view_safety_number), + icon = painterResource(R.drawable.symbol_safety_number_24), + enabled = !state.isDeprecatedOrUnregistered, + onClick = { onEvent(IndividualSettingsEvent.ViewSafetyNumberClicked) } + ) + } + + sharedMediaSection( + media = state.sharedMedia, + loaded = state.sharedMediaLoaded, + onMediaClick = { mediaRecord, isLtr -> onEvent(IndividualSettingsEvent.SharedMediaClicked(mediaRecord, isLtr)) }, + onMediaViewClicked = onSharedMediaViewClicked, + onSeeAllClick = { onEvent(IndividualSettingsEvent.SeeAllSharedMediaClicked) } + ) + + badgeSection(state, onEvent) + groupsInCommonSection(state, onEvent) + + if (state.canModifyBlockedState) { + item { Dividers.Default() } + + item { + BlockRow( + isBlocked = state.recipient.isBlocked, + enabled = !state.isDeprecatedOrUnregistered, + onClick = { onEvent(IndividualSettingsEvent.BlockClicked) } + ) + } + + item { + ReportSpamRow( + enabled = !state.isDeprecatedOrUnregistered, + onClick = { onEvent(IndividualSettingsEvent.ReportSpamClicked) } + ) + } + } + } + + IndividualSettingsDialogs( + state = state, + onEvent = onEvent + ) +} + +/** The recipient's badges, and a nudge toward getting your own. */ +private fun LazyListScope.badgeSection( + state: IndividualSettingsState, + onEvent: (IndividualSettingsEvent) -> Unit +) { + if (state.recipient.badges.isEmpty()) { + return + } + + item { Dividers.Default() } + + item { Texts.SectionHeader(text = stringResource(R.string.ManageProfileFragment_badges)) } + + item { + BadgeRow( + badges = state.recipient.badges, + onBadgeClick = { onEvent(IndividualSettingsEvent.BadgeClicked(it)) } + ) + } + + item { + Text( + text = stringResource(R.string.ConversationSettingsFragment__get_badges), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp) + ) + } +} + +/** Groups the two of you are both in. */ +private fun LazyListScope.groupsInCommonSection( + state: IndividualSettingsState, + onEvent: (IndividualSettingsEvent) -> Unit +) { + if (!state.selfHasGroups) { + return + } + + item { Dividers.Default() } + + item { + val count = state.allGroupsInCommon.size + Texts.SectionHeader( + text = if (count == 0) { + stringResource(R.string.ManageRecipientActivity_no_groups_in_common) + } else { + pluralStringResource(R.plurals.ManageRecipientActivity_d_groups_in_common, count, count) + } + ) + } + + if (!state.recipient.isBlocked) { + item { + LargeIconRow( + text = stringResource(R.string.ConversationSettingsFragment__add_to_a_group), + icon = R.drawable.ic_plus_24, + enabled = !state.isDeprecatedOrUnregistered, + onClick = { onEvent(IndividualSettingsEvent.AddToAGroupClicked) } + ) + } + } + + items( + items = state.groupsInCommon, + key = { it.id.toLong() } + ) { group -> + RecipientRow( + recipient = group, + onClick = { onEvent(IndividualSettingsEvent.GroupInCommonClicked(group.id)) } + ) + } + + if (state.canShowMoreGroupsInCommon) { + item { + LargeIconRow( + text = stringResource(R.string.ConversationSettingsFragment__see_all), + icon = R.drawable.ic_chevron_down_icon_20, + onClick = { onEvent(IndividualSettingsEvent.RevealAllGroupsInCommonClicked) } + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun BadgeRow( + badges: List, + onBadgeClick: (Badge) -> Unit, + modifier: Modifier = Modifier +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp) + ) { + badges.forEach { badge -> + AndroidView( + factory = { context -> BadgeImageView(context, null) }, + modifier = Modifier.size(BADGE_SIZE) + ) { badgeView -> + badgeView.setBadge(badge) + badgeView.setOnClickListener { onBadgeClick(badge) } + } + } + } +} + +@Composable +private fun IndividualSettingsDialogs( + state: IndividualSettingsState, + onEvent: (IndividualSettingsEvent) -> Unit +) { + when (state.dialog) { + Dialog.Unmute -> UnmuteDialog( + recipient = state.recipient, + onConfirm = { onEvent(IndividualSettingsEvent.UnmuteConfirmed) }, + onDismiss = { onEvent(IndividualSettingsEvent.DialogDismissed) } + ) + + // The mute menu is a dropdown anchored to the call bar, so CallBar renders it rather than us. + Dialog.MuteMenu, Dialog.None -> Unit + } +} + +@AllDevicePreviews +@Composable +private fun IndividualSettingsScreenPreview() { + Previews.Preview { + IndividualSettingsScreen( + state = IndividualSettingsState( + recipient = previewRecipient(1L, profileName = ProfileName.fromParts("Miles", "Morales"), about = "Just hanging around"), + threadId = 1L, + sharedMediaLoaded = true, + canModifyBlockedState = true, + starredMessagesEnabled = true, + selfHasGroups = true, + allGroupsInCommon = listOf(previewRecipient(2L, groupName = "Spider Society")), + callBar = CallBarState( + isVideoAvailable = true, + isAudioAvailable = true, + isAudioSecure = true, + isMuteAvailable = true, + isSearchAvailable = true + ) + ), + onEvent = {}, + onNavigationClick = {}, + onAvatarViewCreated = {}, + onSharedMediaViewClicked = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsState.kt new file mode 100644 index 0000000000..9a9525ad08 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsState.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.individual + +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBarState +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallEntry +import org.thoughtcrime.securesms.components.settings.conversation.shared.CollapsibleList +import org.thoughtcrime.securesms.database.MediaTable +import org.thoughtcrime.securesms.database.model.IdentityRecord +import org.thoughtcrime.securesms.database.model.StoryViewState +import org.thoughtcrime.securesms.recipients.Recipient + +data class IndividualSettingsState( + val recipient: Recipient = Recipient.UNKNOWN, + val threadId: Long = -1L, + val storyViewState: StoryViewState = StoryViewState.NONE, + val isDeprecatedOrUnregistered: Boolean = false, + val isInternalUser: Boolean = false, + val displayInternalRecipientDetails: Boolean = false, + val starredMessagesEnabled: Boolean = false, + val disappearingMessagesLifespan: Int = 0, + val canModifyBlockedState: Boolean = false, + val identityRecord: IdentityRecord? = null, + val contactLinkState: ContactLinkState = ContactLinkState.NONE, + val allGroupsInCommon: List = emptyList(), + val selfHasGroups: Boolean = false, + val groupsInCommonExpanded: Boolean = false, + val sharedMedia: List = emptyList(), + val sharedMediaLoaded: Boolean = false, + val calls: List = emptyList(), + val callBar: CallBarState = CallBarState(), + val dialog: Dialog = Dialog.None +) { + + /** + * True once we've loaded enough to render the screen without it visibly shuffling around. Shared media is + * deliberately not part of this: the rail reserves its space while loading, so there's no reason to hold the whole + * screen behind it. + */ + val isLoaded: Boolean = recipient != Recipient.UNKNOWN + + val groupsInCommon: List = CollapsibleList.collapse(allGroupsInCommon, groupsInCommonExpanded) + + val canShowMoreGroupsInCommon: Boolean = CollapsibleList.canExpand(allGroupsInCommon, groupsInCommonExpanded) + + sealed interface Dialog { + data object None : Dialog + data object MuteMenu : Dialog + data object Unmute : Dialog + } +} + +/** Whether we can offer to open the recipient's system contact entry, offer to create one, or neither. */ +enum class ContactLinkState { + OPEN, + ADD, + NONE +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsViewModel.kt new file mode 100644 index 0000000000..70200a1dc7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsViewModel.kt @@ -0,0 +1,303 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.individual + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.signal.core.ui.compose.EventDrivenViewModel +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsAction +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsKind +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository +import org.thoughtcrime.securesms.components.settings.conversation.individual.IndividualSettingsState.Dialog +import org.thoughtcrime.securesms.components.settings.conversation.shared.BlockAndSpamHandler +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBarState +import org.thoughtcrime.securesms.components.settings.conversation.shared.SharedMediaLoader +import org.thoughtcrime.securesms.components.settings.conversation.shared.sharedMediaClickAction +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * View model behind all three 1:1 settings screens: [IndividualSettingsScreen], [NoteToSelfSettingsScreen], and + * [ReleaseNotesSettingsScreen]. + */ +class IndividualSettingsViewModel( + private val recipientId: RecipientId, + private val kind: ConversationSettingsKind, + private val callMessageIds: LongArray, + private val repository: ConversationSettingsRepository +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(IndividualSettingsViewModel::class) + } + + /** Whether we're the call-info variant of this screen, which shows a message button in place of search. */ + private val isCallInfoVariant: Boolean = callMessageIds.isNotEmpty() + + private val _state = MutableStateFlow( + IndividualSettingsState( + isDeprecatedOrUnregistered = repository.isDeprecatedOrUnregistered(), + starredMessagesEnabled = repository.isStarredMessagesEnabled(), + isInternalUser = repository.isInternalUser(), + displayInternalRecipientDetails = repository.isInternalRecipientDetailsEnabled() + ) + ) + + private val _actions = Channel(Channel.BUFFERED) + + val state: StateFlow = _state.asStateFlow() + val actions: Flow = _actions.receiveAsFlow() + + private val sharedMediaLoader = SharedMediaLoader(repository) + + init { + require(kind != ConversationSettingsKind.GROUP) { "Groups belong to GroupSettingsViewModel" } + + repository + .observeRecipient(recipientId) + .onEach { onEvent(IndividualSettingsEvent.RecipientChanged(it)) } + .launchIn(viewModelScope) + + repository + .observeStoryViewState(recipientId) + .onEach { onEvent(IndividualSettingsEvent.StoryViewStateChanged(it)) } + .launchIn(viewModelScope) + + sharedMediaLoader + .observe() + .onEach { onEvent(IndividualSettingsEvent.SharedMediaChanged(it)) } + .launchIn(viewModelScope) + + if (callMessageIds.isNotEmpty()) { + repository + .observeCalls(_state.map { it.threadId }, callMessageIds) + .onEach { onEvent(IndividualSettingsEvent.CallsChanged(it)) } + .launchIn(viewModelScope) + } + + viewModelScope.launch { + onEvent(IndividualSettingsEvent.ThreadIdLoaded(repository.getThreadId(recipientId))) + } + + // Neither note to self nor the release notes chat shows groups in common or a safety number, so don't go looking. + if (kind == ConversationSettingsKind.INDIVIDUAL) { + repository + .observeGroupsInCommon(recipientId) + .onEach { onEvent(IndividualSettingsEvent.GroupsInCommonChanged(it)) } + .launchIn(viewModelScope) + + viewModelScope.launch { + onEvent(IndividualSettingsEvent.SelfHasGroupsLoaded(repository.hasGroups())) + } + + viewModelScope.launch { + onEvent(IndividualSettingsEvent.IdentityRecordLoaded(repository.getIdentity(recipientId))) + } + } + } + + override suspend fun processEvent(event: IndividualSettingsEvent) { + val state = _state.value + + when (event) { + IndividualSettingsEvent.AvatarClicked -> { + BlockAndSpamHandler.avatarClickAction(state.recipient, state.storyViewState, repository.isStoriesFeatureEnabled())?.let { _actions.send(it) } + } + is IndividualSettingsEvent.BadgeClicked -> { + _actions.send(ConversationSettingsAction.ShowBadgeSheet(recipientId, event.badge)) + } + IndividualSettingsEvent.HeadlineClicked -> { + _actions.send(ConversationSettingsAction.ShowAboutSheet(state.recipient)) + } + IndividualSettingsEvent.InternalDetailsClicked -> { + _actions.send(ConversationSettingsAction.NavigateToInternalDetails(recipientId)) + } + IndividualSettingsEvent.MessageClicked -> { + _actions.send(ConversationSettingsAction.OpenConversation(recipientId, state.threadId)) + } + IndividualSettingsEvent.VideoCallClicked -> { + _actions.send(ConversationSettingsAction.StartVideoCall(state.recipient)) + } + IndividualSettingsEvent.AudioCallClicked -> { + _actions.send(ConversationSettingsAction.StartAudioCall(state.recipient)) + } + IndividualSettingsEvent.MuteClicked -> { + _state.update { it.copy(dialog = if (state.callBar.isMuted) Dialog.Unmute else Dialog.MuteMenu) } + } + is IndividualSettingsEvent.MuteDurationSelected -> { + _state.update { it.copy(dialog = Dialog.None) } + repository.setMuteUntil(recipientId, event.muteUntil) + } + IndividualSettingsEvent.MuteUntilCustomTimeClicked -> { + _state.update { it.copy(dialog = Dialog.None) } + _actions.send(ConversationSettingsAction.ShowMuteUntilTimePicker) + } + IndividualSettingsEvent.UnmuteConfirmed -> { + _state.update { it.copy(dialog = Dialog.None) } + repository.setMuteUntil(recipientId, 0) + } + IndividualSettingsEvent.SearchClicked -> { + _actions.send(ConversationSettingsAction.OpenConversation(recipientId, state.threadId, withSearchOpen = true)) + } + IndividualSettingsEvent.DisappearingMessagesClicked -> { + _actions.send(ConversationSettingsAction.NavigateToDisappearingMessages(recipientId, state.disappearingMessagesLifespan)) + } + IndividualSettingsEvent.NicknameClicked -> { + _actions.send(ConversationSettingsAction.EditNickname(recipientId)) + } + IndividualSettingsEvent.ChatColorAndWallpaperClicked -> { + _actions.send(ConversationSettingsAction.OpenChatWallpaper(recipientId)) + } + IndividualSettingsEvent.SoundsAndNotificationsClicked -> { + _actions.send(ConversationSettingsAction.NavigateToSoundsAndNotifications(recipientId, state.isInternalUser)) + } + IndividualSettingsEvent.StarredMessagesClicked -> { + _actions.send(ConversationSettingsAction.OpenStarredMessages(state.threadId)) + } + IndividualSettingsEvent.ContactDetailsClicked -> { + _actions.send(ConversationSettingsAction.ViewContact(state.recipient)) + } + IndividualSettingsEvent.AddAsContactClicked -> { + _actions.send(ConversationSettingsAction.AddContact(state.recipient)) + } + IndividualSettingsEvent.ViewSafetyNumberClicked -> { + _actions.send(ConversationSettingsAction.ShowSafetyNumber(state.identityRecord)) + } + is IndividualSettingsEvent.SharedMediaClicked -> { + _actions.send(sharedMediaClickAction(event.mediaRecord, event.isLtr)) + } + IndividualSettingsEvent.SeeAllSharedMediaClicked -> { + _actions.send(ConversationSettingsAction.ShowMediaOverview(state.threadId)) + } + IndividualSettingsEvent.SupportCenterClicked -> { + _actions.send(ConversationSettingsAction.OpenSupportCenter) + } + IndividualSettingsEvent.ContactUsClicked -> { + _actions.send(ConversationSettingsAction.OpenContactUs) + } + IndividualSettingsEvent.DonateClicked -> { + _actions.send(ConversationSettingsAction.OpenDonate) + } + IndividualSettingsEvent.AddToAGroupClicked -> { + val groupMembership = repository.getGroupMembership(recipientId) + _actions.send(ConversationSettingsAction.AddToAGroup(recipientId, groupMembership)) + } + is IndividualSettingsEvent.GroupInCommonClicked -> { + val group = state.allGroupsInCommon.firstOrNull { it.id == event.recipientId } + if (group != null) { + _actions.send(ConversationSettingsAction.OpenGroupConversation(group)) + } + } + IndividualSettingsEvent.RevealAllGroupsInCommonClicked -> { + _state.update { it.copy(groupsInCommonExpanded = true) } + } + IndividualSettingsEvent.BlockClicked -> { + _actions.send(BlockAndSpamHandler.blockAction(state.recipient)) + } + IndividualSettingsEvent.BlockConfirmed -> { + val result = repository.block(recipientId) + if (!result.isSuccess) { + _actions.send(ConversationSettingsAction.ShowBlockError(result.getFailureReason())) + } + } + IndividualSettingsEvent.UnblockConfirmed -> { + repository.unblock(recipientId) + } + IndividualSettingsEvent.ReportSpamClicked -> { + _actions.send(BlockAndSpamHandler.reportSpamAction(state.recipient)) + } + IndividualSettingsEvent.ReportSpamConfirmed -> { + BlockAndSpamHandler.reportSpam(state.recipient, state.threadId, repository) { _actions.send(it) } + } + IndividualSettingsEvent.BlockAndReportSpamConfirmed -> { + BlockAndSpamHandler.blockAndReportSpam(state.recipient, state.threadId, repository) { _actions.send(it) } + } + IndividualSettingsEvent.DialogDismissed -> { + _state.update { it.copy(dialog = Dialog.None) } + } + IndividualSettingsEvent.SharedMediaRefreshRequested -> { + sharedMediaLoader.refresh() + } + IndividualSettingsEvent.RecipientRefreshRequested -> { + repository.refreshRecipient(recipientId) + } + is IndividualSettingsEvent.RecipientChanged -> { + _state.update { it.applyRecipient(event.recipient) } + } + is IndividualSettingsEvent.StoryViewStateChanged -> { + _state.update { it.copy(storyViewState = event.storyViewState) } + } + is IndividualSettingsEvent.SharedMediaChanged -> { + _state.update { it.copy(sharedMedia = event.media, sharedMediaLoaded = true) } + } + is IndividualSettingsEvent.CallsChanged -> { + _state.update { it.copy(calls = event.calls) } + } + is IndividualSettingsEvent.ThreadIdLoaded -> { + _state.update { it.copy(threadId = event.threadId) } + sharedMediaLoader.onThreadIdLoaded(event.threadId) + } + is IndividualSettingsEvent.GroupsInCommonChanged -> { + _state.update { it.copy(allGroupsInCommon = event.groupsInCommon) } + } + is IndividualSettingsEvent.SelfHasGroupsLoaded -> { + _state.update { it.copy(selfHasGroups = event.selfHasGroups) } + } + is IndividualSettingsEvent.IdentityRecordLoaded -> { + _state.update { it.copy(identityRecord = event.identityRecord) } + } + } + } + + private fun IndividualSettingsState.applyRecipient(recipient: Recipient): IndividualSettingsState { + val isReachable = !recipient.isBlocked && !recipient.isSelf && !recipient.isReleaseNotes + + return copy( + recipient = recipient, + callBar = CallBarState( + isMessageAvailable = isCallInfoVariant, + isVideoAvailable = recipient.isRegistered && isReachable, + isAudioAvailable = recipient.isRegistered && isReachable, + isAudioSecure = recipient.isRegistered, + isMuteAvailable = !recipient.isSelf, + isMuted = recipient.isMuted, + isSearchAvailable = !isCallInfoVariant + ), + disappearingMessagesLifespan = recipient.expiresInSeconds, + canModifyBlockedState = !recipient.isSelf && repository.isBlockable(recipient), + contactLinkState = when { + recipient.isSelf || recipient.isReleaseNotes || recipient.isBlocked -> ContactLinkState.NONE + recipient.isSystemContact -> ContactLinkState.OPEN + recipient.hasE164 && recipient.shouldShowE164 -> ContactLinkState.ADD + else -> ContactLinkState.NONE + } + ) + } + + class Factory( + private val recipientId: RecipientId, + private val kind: ConversationSettingsKind, + private val callMessageIds: LongArray, + private val repository: ConversationSettingsRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return requireNotNull(modelClass.cast(IndividualSettingsViewModel(recipientId, kind, callMessageIds, repository))) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/NoteToSelfSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/NoteToSelfSettingsScreen.kt new file mode 100644 index 0000000000..efcbef4682 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/NoteToSelfSettingsScreen.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.individual + +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import org.signal.core.ui.compose.AllDevicePreviews +import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.Previews +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBar +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBarState +import org.thoughtcrime.securesms.components.settings.conversation.shared.ChatColorAndWallpaperRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.ConversationHeader +import org.thoughtcrime.securesms.components.settings.conversation.shared.ConversationSettingsScaffold +import org.thoughtcrime.securesms.components.settings.conversation.shared.DisappearingMessagesRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.InternalDetailsButton +import org.thoughtcrime.securesms.components.settings.conversation.shared.StarredMessagesRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.previewRecipient +import org.thoughtcrime.securesms.components.settings.conversation.shared.sharedMediaSection +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * Settings for the note to self chat. + */ +@Composable +fun NoteToSelfSettingsScreen( + state: IndividualSettingsState, + onEvent: (IndividualSettingsEvent) -> Unit, + onNavigationClick: () -> Unit, + onAvatarViewCreated: (View) -> Unit, + onSharedMediaViewClicked: (View) -> Unit, + modifier: Modifier = Modifier +) { + ConversationSettingsScaffold( + title = stringResource(R.string.note_to_self), + recipient = state.recipient, + onNavigationClick = onNavigationClick, + modifier = modifier + ) { + if (state.recipient == Recipient.UNKNOWN) { + return@ConversationSettingsScaffold + } + + item { + ConversationHeader( + recipient = state.recipient, + name = stringResource(R.string.note_to_self), + storyViewState = state.storyViewState, + showVerifiedBadge = state.recipient.showVerified, + onAvatarClick = { onEvent(IndividualSettingsEvent.AvatarClicked) }, + onAvatarViewCreated = onAvatarViewCreated + ) + } + + if (state.displayInternalRecipientDetails) { + item { + InternalDetailsButton(onClick = { onEvent(IndividualSettingsEvent.InternalDetailsClicked) }) + } + } + + item { + CallBar( + state = state.callBar, + enabled = !state.isDeprecatedOrUnregistered, + onAddToStoryClick = {}, + onMessageClick = {}, + onVideoCallClick = {}, + onAudioCallClick = {}, + onMuteClick = {}, + onMuteDurationSelected = {}, + onMuteUntilCustomTimeClick = {}, + onMuteMenuDismissed = {}, + onSearchClick = { onEvent(IndividualSettingsEvent.SearchClicked) } + ) + } + + item { Dividers.Default() } + + item { + DisappearingMessagesRow( + lifespanSeconds = state.disappearingMessagesLifespan, + enabled = !state.isDeprecatedOrUnregistered, + onClick = { onEvent(IndividualSettingsEvent.DisappearingMessagesClicked) } + ) + } + + item { + ChatColorAndWallpaperRow(onClick = { onEvent(IndividualSettingsEvent.ChatColorAndWallpaperClicked) }) + } + + if (state.starredMessagesEnabled) { + item { + StarredMessagesRow(onClick = { onEvent(IndividualSettingsEvent.StarredMessagesClicked) }) + } + } + + sharedMediaSection( + media = state.sharedMedia, + loaded = state.sharedMediaLoaded, + onMediaClick = { mediaRecord, isLtr -> onEvent(IndividualSettingsEvent.SharedMediaClicked(mediaRecord, isLtr)) }, + onMediaViewClicked = onSharedMediaViewClicked, + onSeeAllClick = { onEvent(IndividualSettingsEvent.SeeAllSharedMediaClicked) } + ) + } +} + +@AllDevicePreviews +@Composable +private fun NoteToSelfSettingsScreenPreview() { + Previews.Preview { + NoteToSelfSettingsScreen( + state = IndividualSettingsState( + recipient = previewRecipient(1L, isSelf = true), + threadId = 1L, + sharedMediaLoaded = true, + starredMessagesEnabled = true, + callBar = CallBarState(isSearchAvailable = true) + ), + onEvent = {}, + onNavigationClick = {}, + onAvatarViewCreated = {}, + onSharedMediaViewClicked = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/ReleaseNotesSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/ReleaseNotesSettingsScreen.kt new file mode 100644 index 0000000000..1e2d92a1da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/individual/ReleaseNotesSettingsScreen.kt @@ -0,0 +1,207 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.individual + +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import org.signal.core.ui.compose.AllDevicePreviews +import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Texts +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.conversation.individual.IndividualSettingsState.Dialog +import org.thoughtcrime.securesms.components.settings.conversation.shared.BlockRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBar +import org.thoughtcrime.securesms.components.settings.conversation.shared.CallBarState +import org.thoughtcrime.securesms.components.settings.conversation.shared.ConversationHeader +import org.thoughtcrime.securesms.components.settings.conversation.shared.ConversationSettingsScaffold +import org.thoughtcrime.securesms.components.settings.conversation.shared.InternalDetailsButton +import org.thoughtcrime.securesms.components.settings.conversation.shared.SoundsAndNotificationsRow +import org.thoughtcrime.securesms.components.settings.conversation.shared.UnmuteDialog +import org.thoughtcrime.securesms.components.settings.conversation.shared.previewRecipient +import org.thoughtcrime.securesms.components.settings.conversation.shared.sharedMediaSection +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * Settings for the Signal release notes chat. + */ +@Composable +fun ReleaseNotesSettingsScreen( + state: IndividualSettingsState, + onEvent: (IndividualSettingsEvent) -> Unit, + onNavigationClick: () -> Unit, + onAvatarViewCreated: (View) -> Unit, + onSharedMediaViewClicked: (View) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + ConversationSettingsScaffold( + title = state.recipient.getDisplayName(context), + recipient = state.recipient, + onNavigationClick = onNavigationClick, + modifier = modifier + ) { + if (state.recipient == Recipient.UNKNOWN) { + return@ConversationSettingsScaffold + } + + item { + ConversationHeader( + recipient = state.recipient, + name = state.recipient.getDisplayName(context), + showVerifiedBadge = state.recipient.showVerified, + onAvatarClick = { onEvent(IndividualSettingsEvent.AvatarClicked) }, + onAvatarViewCreated = onAvatarViewCreated + ) + } + + if (state.displayInternalRecipientDetails) { + item { + InternalDetailsButton(onClick = { onEvent(IndividualSettingsEvent.InternalDetailsClicked) }) + } + } + + item { + CallBar( + state = state.callBar, + enabled = !state.isDeprecatedOrUnregistered, + isMuteMenuShown = state.dialog == Dialog.MuteMenu, + onAddToStoryClick = {}, + onMessageClick = {}, + onVideoCallClick = {}, + onAudioCallClick = {}, + onMuteClick = { onEvent(IndividualSettingsEvent.MuteClicked) }, + onMuteDurationSelected = { onEvent(IndividualSettingsEvent.MuteDurationSelected(it)) }, + onMuteUntilCustomTimeClick = { onEvent(IndividualSettingsEvent.MuteUntilCustomTimeClicked) }, + onMuteMenuDismissed = { onEvent(IndividualSettingsEvent.DialogDismissed) }, + onSearchClick = { onEvent(IndividualSettingsEvent.SearchClicked) } + ) + } + + item { Dividers.Default() } + + item { + Rows.TextRow( + text = stringResource(R.string.ReleaseNotes__this_is_official_chat), + icon = painterResource(R.drawable.symbol_official_20) + ) + } + + item { + Rows.TextRow( + text = stringResource(R.string.ReleaseNotes__keep_up_to_date), + icon = painterResource(R.drawable.symbol_bell_20) + ) + } + + item { Dividers.Default() } + + item { + SoundsAndNotificationsRow( + isInternalUser = state.isInternalUser, + enabled = !state.isDeprecatedOrUnregistered, + onClick = { onEvent(IndividualSettingsEvent.SoundsAndNotificationsClicked) } + ) + } + + sharedMediaSection( + media = state.sharedMedia, + loaded = state.sharedMediaLoaded, + onMediaClick = { mediaRecord, isLtr -> onEvent(IndividualSettingsEvent.SharedMediaClicked(mediaRecord, isLtr)) }, + onMediaViewClicked = onSharedMediaViewClicked, + onSeeAllClick = { onEvent(IndividualSettingsEvent.SeeAllSharedMediaClicked) } + ) + + item { Dividers.Default() } + + item { Texts.SectionHeader(text = stringResource(R.string.preferences__help)) } + + item { + Rows.TextRow( + text = stringResource(R.string.HelpSettingsFragment__support_center), + icon = painterResource(R.drawable.symbol_help_24), + onClick = { onEvent(IndividualSettingsEvent.SupportCenterClicked) } + ) + } + + item { + Rows.TextRow( + text = stringResource(R.string.HelpSettingsFragment__contact_us), + icon = painterResource(R.drawable.symbol_invite_24), + onClick = { onEvent(IndividualSettingsEvent.ContactUsClicked) } + ) + } + + item { + Rows.TextRow( + text = stringResource(R.string.preferences__donate_to_signal), + icon = painterResource(R.drawable.symbol_heart_24), + onClick = { onEvent(IndividualSettingsEvent.DonateClicked) } + ) + } + + if (state.canModifyBlockedState) { + item { Dividers.Default() } + + item { + BlockRow( + isBlocked = state.recipient.isBlocked, + enabled = !state.isDeprecatedOrUnregistered, + onClick = { onEvent(IndividualSettingsEvent.BlockClicked) } + ) + } + } + } + + ReleaseNotesSettingsDialogs( + state = state, + onEvent = onEvent + ) +} + +@Composable +private fun ReleaseNotesSettingsDialogs( + state: IndividualSettingsState, + onEvent: (IndividualSettingsEvent) -> Unit +) { + when (state.dialog) { + Dialog.Unmute -> UnmuteDialog( + recipient = state.recipient, + onConfirm = { onEvent(IndividualSettingsEvent.UnmuteConfirmed) }, + onDismiss = { onEvent(IndividualSettingsEvent.DialogDismissed) } + ) + + // The mute menu is a dropdown anchored to the call bar, so CallBar renders it rather than us. + Dialog.MuteMenu, Dialog.None -> Unit + } +} + +@AllDevicePreviews +@Composable +private fun ReleaseNotesSettingsScreenPreview() { + Previews.Preview { + ReleaseNotesSettingsScreen( + state = IndividualSettingsState( + recipient = previewRecipient(1L, profileName = ProfileName.fromParts("Signal", null), isReleaseNotes = true), + threadId = 1L, + sharedMediaLoaded = true, + canModifyBlockedState = true, + callBar = CallBarState(isMuteAvailable = true, isSearchAvailable = true) + ), + onEvent = {}, + onNavigationClick = {}, + onAvatarViewCreated = {}, + onSharedMediaViewClicked = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt deleted file mode 100644 index 3ff63e0512..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/AvatarPreference.kt +++ /dev/null @@ -1,71 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.conversation.preferences - -import android.view.View -import androidx.core.view.ViewCompat -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.avatar.view.AvatarView -import org.thoughtcrime.securesms.badges.BadgeImageView -import org.thoughtcrime.securesms.badges.models.Badge -import org.thoughtcrime.securesms.components.settings.PreferenceModel -import org.thoughtcrime.securesms.database.model.StoryViewState -import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder - -/** - * Renders a large avatar (80dp) for a given Recipient. - */ -object AvatarPreference { - - fun register(adapter: MappingAdapter) { - adapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.conversation_settings_avatar_preference_item)) - } - - class Model( - val recipient: Recipient, - val storyViewState: StoryViewState, - val onAvatarClick: (AvatarView) -> Unit, - val onBadgeClick: (Badge) -> Unit - ) : PreferenceModel() { - override fun areItemsTheSame(newItem: Model): Boolean { - return recipient == newItem.recipient - } - - override fun areContentsTheSame(newItem: Model): Boolean { - return super.areContentsTheSame(newItem) && - recipient.hasSameContent(newItem.recipient) && - storyViewState == newItem.storyViewState - } - } - - private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { - private val avatar: AvatarView = itemView.findViewById(R.id.bio_preference_avatar) - - private val badge: BadgeImageView = itemView.findViewById(R.id.bio_preference_badge) - - init { - ViewCompat.setTransitionName(avatar.parent as View, "avatar") - } - - override fun bind(model: Model) { - if (model.recipient.isSelf) { - badge.setBadge(null) - badge.setOnClickListener(null) - } else { - badge.setBadgeFromRecipient(model.recipient) - badge.setOnClickListener { - val badge = model.recipient.badges.firstOrNull() - if (badge != null) { - model.onBadgeClick(badge) - } - } - } - - avatar.setStoryRingFromState(model.storyViewState) - avatar.displayChatAvatar(model.recipient) - avatar.disableQuickContact() - avatar.setOnClickListener { model.onAvatarClick(avatar) } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/BioTextPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/BioTextPreference.kt deleted file mode 100644 index f772a5510d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/BioTextPreference.kt +++ /dev/null @@ -1,144 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.conversation.preferences - -import android.content.ClipData -import android.content.Context -import android.text.SpannableStringBuilder -import android.view.View -import android.widget.TextView -import android.widget.Toast -import org.signal.core.ui.fonts.SignalSymbols -import org.signal.core.util.ServiceUtil -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.settings.PreferenceModel -import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder - -/** - * Renders name, description, about, etc. for a given group or recipient. - */ -object BioTextPreference { - - fun register(adapter: MappingAdapter) { - adapter.registerFactory(RecipientModel::class.java, LayoutFactory(::RecipientViewHolder, R.layout.conversation_settings_bio_preference_item)) - adapter.registerFactory(GroupModel::class.java, LayoutFactory(::GroupViewHolder, R.layout.conversation_settings_bio_preference_item)) - } - - abstract class BioTextPreferenceModel> : PreferenceModel() { - abstract fun getHeadlineText(context: Context): CharSequence - abstract fun getSubhead1Text(context: Context): String? - abstract fun getSubhead2Text(): String? - - open val onHeadlineClickListener: (() -> Unit)? = null - } - - class RecipientModel( - private val recipient: Recipient, - override val onHeadlineClickListener: (() -> Unit)? - ) : BioTextPreferenceModel() { - - override fun getHeadlineText(context: Context): CharSequence { - return recipient.getDisplayNameForHeadline(context) - } - - override fun getSubhead1Text(context: Context): String? { - return if (recipient.isReleaseNotes) { - null - } else { - recipient.combinedAboutAndEmoji - } - } - - override fun getSubhead2Text(): String? = null - - override fun areContentsTheSame(newItem: RecipientModel): Boolean { - return super.areContentsTheSame(newItem) && newItem.recipient.hasSameContent(recipient) - } - - override fun areItemsTheSame(newItem: RecipientModel): Boolean { - return newItem.recipient.id == recipient.id - } - } - - class GroupModel( - val groupTitle: String, - val groupMembershipDescription: String?, - val isTerminated: Boolean = false - ) : BioTextPreferenceModel() { - override fun getHeadlineText(context: Context): CharSequence = groupTitle - - override fun getSubhead1Text(context: Context): String? = groupMembershipDescription - - override fun getSubhead2Text(): String? = null - - override fun areContentsTheSame(newItem: GroupModel): Boolean { - return super.areContentsTheSame(newItem) && - groupTitle == newItem.groupTitle && - groupMembershipDescription == newItem.groupMembershipDescription && - isTerminated == newItem.isTerminated - } - - override fun areItemsTheSame(newItem: GroupModel): Boolean { - return true - } - } - - private abstract class BioTextViewHolder>(itemView: View) : MappingViewHolder(itemView) { - - private val headline: TextView = itemView.findViewById(R.id.bio_preference_headline) - private val subhead1: TextView = itemView.findViewById(R.id.bio_preference_subhead_1) - protected val subhead2: TextView = itemView.findViewById(R.id.bio_preference_subhead_2) - private val terminatedPill: TextView = itemView.findViewById(R.id.bio_preference_terminated_pill) - - override fun bind(model: T) { - headline.text = model.getHeadlineText(context) - - val clickListener = model.onHeadlineClickListener - if (clickListener != null) { - headline.setOnClickListener { clickListener() } - } - - if (model is GroupModel && model.isTerminated) { - val glyphSpan = SignalSymbols.getSpannedString(context, SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.GROUP_X) - terminatedPill.text = SpannableStringBuilder() - .append(glyphSpan) - .append(" ") - .append(context.getString(R.string.ConversationSettingsFragment__this_group_was_ended)) - terminatedPill.visibility = View.VISIBLE - } else { - terminatedPill.visibility = View.GONE - } - - model.getSubhead1Text(context).let { - subhead1.text = it - subhead1.visibility = if (it == null) View.GONE else View.VISIBLE - } - - model.getSubhead2Text().let { - subhead2.text = it - subhead2.visibility = if (it == null) View.GONE else View.VISIBLE - } - } - } - - private class RecipientViewHolder(itemView: View) : BioTextViewHolder(itemView) { - override fun bind(model: RecipientModel) { - super.bind(model) - - val phoneNumber = model.getSubhead2Text() - if (!phoneNumber.isNullOrEmpty()) { - subhead2.setOnLongClickListener { - val clipboardManager = ServiceUtil.getClipboardManager(context) - clipboardManager.setPrimaryClip(ClipData.newPlainText(context.getString(R.string.ConversationSettingsFragment__phone_number), subhead2.text.toString())) - Toast.makeText(context, R.string.ConversationSettingsFragment__copied_phone_number_to_clipboard, Toast.LENGTH_SHORT).show() - true - } - } else { - subhead2.setOnLongClickListener(null) - } - } - } - - private class GroupViewHolder(itemView: View) : BioTextViewHolder(itemView) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/CallPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/CallPreference.kt deleted file mode 100644 index 03da020a41..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/CallPreference.kt +++ /dev/null @@ -1,133 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.conversation.preferences - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.database.CallTable -import org.thoughtcrime.securesms.database.MessageTypes -import org.thoughtcrime.securesms.database.model.MessageRecord -import org.thoughtcrime.securesms.databinding.ConversationSettingsCallPreferenceItemBinding -import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.thoughtcrime.securesms.util.DateUtils -import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory -import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel -import org.thoughtcrime.securesms.util.visible - -/** - * Renders a single call preference row when displaying call info. - */ -object CallPreference { - fun register(mappingAdapter: MappingAdapter) { - mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, ConversationSettingsCallPreferenceItemBinding::inflate)) - } - - class Model( - val call: CallTable.Call, - val record: MessageRecord - ) : MappingModel { - override fun areItemsTheSame(newItem: Model): Boolean = record.id == newItem.record.id - - override fun areContentsTheSame(newItem: Model): Boolean { - return call == newItem.call && - record.type == newItem.record.type && - record.isOutgoing == newItem.record.isOutgoing && - record.timestamp == newItem.record.timestamp && - record.id == newItem.record.id - } - } - - private class ViewHolder(binding: ConversationSettingsCallPreferenceItemBinding) : BindingViewHolder(binding) { - override fun bind(model: Model) { - binding.callIcon.setImageResource(getCallIcon(model.call)) - binding.callType.text = getCallType(model.call) - binding.callTime.text = getCallTime(model.record) - presentTimer(model.record) - } - - private fun presentTimer(messageRecord: MessageRecord) { - if (messageRecord.expiresIn > 0 && messageRecord.expireStarted > 0) { - binding.callTimer.visible = true - binding.callTimer.setPercentComplete(0f) - - if (messageRecord.expireStarted > 0) { - binding.callTimer.setExpirationTime(messageRecord.expireStarted, messageRecord.expiresIn) - binding.callTimer.startAnimation() - - if (messageRecord.expireStarted + messageRecord.expiresIn <= System.currentTimeMillis()) { - AppDependencies.expiringMessageManager.checkSchedule() - } - } - } else { - binding.callTimer.visible = false - } - } - - @DrawableRes - private fun getCallIcon(call: CallTable.Call): Int { - return when (call.messageType) { - MessageTypes.MISSED_VIDEO_CALL_TYPE, MessageTypes.MISSED_AUDIO_CALL_TYPE -> R.drawable.symbol_missed_incoming_24 - MessageTypes.INCOMING_AUDIO_CALL_TYPE, MessageTypes.INCOMING_VIDEO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) R.drawable.symbol_missed_incoming_24 else R.drawable.symbol_arrow_downleft_24 - MessageTypes.OUTGOING_AUDIO_CALL_TYPE, MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_upright_24 - MessageTypes.GROUP_CALL_TYPE -> when { - call.isDisplayedAsMissedCallInUi -> R.drawable.symbol_missed_incoming_24 - call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.drawable.symbol_group_24 - call.direction == CallTable.Direction.INCOMING -> R.drawable.symbol_arrow_downleft_24 - call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_24 - else -> throw AssertionError() - } - else -> error("Unexpected type ${call.type}") - } - } - - private fun getCallType(call: CallTable.Call): String { - val id = when (call.messageType) { - MessageTypes.MISSED_AUDIO_CALL_TYPE -> getMissedCallString(false, call.event) - MessageTypes.MISSED_VIDEO_CALL_TYPE -> getMissedCallString(true, call.event) - MessageTypes.INCOMING_AUDIO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) getMissedCallString(false, call.event) else R.string.MessageRecord_incoming_voice_call - MessageTypes.INCOMING_VIDEO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) getMissedCallString(true, call.event) else R.string.MessageRecord_incoming_video_call - MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> if (call.event == CallTable.Event.NOT_ACCEPTED) R.string.MessageRecord_unanswered_voice_call else R.string.MessageRecord_outgoing_voice_call - MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> if (call.event == CallTable.Event.NOT_ACCEPTED) R.string.MessageRecord_unanswered_video_call else R.string.MessageRecord_outgoing_video_call - MessageTypes.GROUP_CALL_TYPE -> when { - call.isDisplayedAsMissedCallInUi -> if (call.event == CallTable.Event.MISSED_NOTIFICATION_PROFILE) R.string.CallPreference__missed_group_call_notification_profile else R.string.CallPreference__missed_group_call - call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.string.CallPreference__group_call - call.direction == CallTable.Direction.INCOMING -> R.string.CallPreference__incoming_group_call - call.direction == CallTable.Direction.OUTGOING -> R.string.CallPreference__outgoing_group_call - else -> throw AssertionError() - } - else -> error("Unexpected type ${call.messageType}") - } - - return context.getString(id) - } - - @StringRes - private fun getMissedCallString(isVideo: Boolean, callEvent: CallTable.Event): Int { - return when (callEvent) { - CallTable.Event.MISSED_NOTIFICATION_PROFILE -> - if (isVideo) { - R.string.MessageRecord_missed_video_call_notification_profile - } else { - R.string.MessageRecord_missed_voice_call_notification_profile - } - CallTable.Event.NOT_ACCEPTED -> - if (isVideo) { - R.string.MessageRecord_declined_video_call - } else { - R.string.MessageRecord_declined_voice_call - } - else -> - if (isVideo) { - R.string.MessageRecord_missed_video_call - } else { - R.string.MessageRecord_missed_voice_call - } - } - } - - private fun getCallTime(messageRecord: MessageRecord): String { - return DateUtils.getOnlyTimeString(context, messageRecord.timestamp) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/GroupDescriptionPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/GroupDescriptionPreference.kt deleted file mode 100644 index 8989e7b194..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/GroupDescriptionPreference.kt +++ /dev/null @@ -1,66 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.conversation.preferences - -import android.view.View -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.emoji.EmojiTextView -import org.thoughtcrime.securesms.components.settings.PreferenceModel -import org.thoughtcrime.securesms.groups.GroupId -import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil -import org.thoughtcrime.securesms.util.LongClickMovementMethod -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 GroupDescriptionPreference { - - fun register(adapter: MappingAdapter) { - adapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.conversation_settings_group_description_preference)) - } - - class Model( - private val groupId: GroupId, - val groupDescription: String?, - val descriptionShouldLinkify: Boolean, - val canEditGroupAttributes: Boolean, - val onEditGroupDescription: () -> Unit, - val onViewGroupDescription: () -> Unit - ) : PreferenceModel() { - override fun areItemsTheSame(newItem: Model): Boolean { - return groupId == newItem.groupId - } - - override fun areContentsTheSame(newItem: Model): Boolean { - return super.areContentsTheSame(newItem) && - groupDescription == newItem.groupDescription && - descriptionShouldLinkify == newItem.descriptionShouldLinkify && - canEditGroupAttributes == newItem.canEditGroupAttributes - } - } - - class ViewHolder(itemView: View) : MappingViewHolder(itemView) { - - private val groupDescriptionTextView: EmojiTextView = findViewById(R.id.manage_group_description) - - override fun bind(model: Model) { - groupDescriptionTextView.movementMethod = LongClickMovementMethod.getInstance(context) - - if (model.groupDescription.isNullOrEmpty()) { - if (model.canEditGroupAttributes) { - groupDescriptionTextView.setOverflowText(null) - groupDescriptionTextView.setText(R.string.ManageGroupActivity_add_group_description) - groupDescriptionTextView.setOnClickListener { model.onEditGroupDescription() } - } - } else { - groupDescriptionTextView.setOnClickListener(null) - GroupDescriptionUtil.setText( - context, - groupDescriptionTextView, - model.groupDescription, - model.descriptionShouldLinkify - ) { - model.onViewGroupDescription() - } - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/LegacyGroupPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/LegacyGroupPreference.kt deleted file mode 100644 index 0936529dd7..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/LegacyGroupPreference.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.conversation.preferences - -import android.view.View -import androidx.core.content.ContextCompat -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 -import org.thoughtcrime.securesms.util.views.LearnMoreTextView - -object LegacyGroupPreference { - - fun register(adapter: MappingAdapter) { - adapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.conversation_settings_legacy_group_preference)) - } - - class Model( - val state: State, - val onLearnMoreClick: () -> Unit, - val onMmsWarningClick: () -> Unit - ) : PreferenceModel() { - override fun areItemsTheSame(newItem: Model): Boolean { - return state == newItem.state - } - } - - private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { - - private val groupInfoText: LearnMoreTextView = findViewById(R.id.manage_group_info_text) - - override fun bind(model: Model) { - itemView.visibility = View.VISIBLE - - groupInfoText.setLinkColor(ContextCompat.getColor(context, R.color.signal_text_primary)) - - when (model.state) { - State.LEARN_MORE -> { - groupInfoText.setText(R.string.ManageGroupActivity_legacy_group_learn_more) - groupInfoText.setOnLinkClickListener { model.onLearnMoreClick() } - groupInfoText.setLearnMoreVisible(true) - } - State.MMS_WARNING -> { - groupInfoText.setText(R.string.ManageGroupActivity_this_is_an_insecure_mms_group) - groupInfoText.setOnLinkClickListener { model.onMmsWarningClick() } - groupInfoText.setLearnMoreVisible(true, R.string.ManageGroupActivity_invite_now) - } - State.NONE -> itemView.visibility = View.GONE - } - } - } - - enum class State { - LEARN_MORE, - MMS_WARNING, - NONE - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/SharedMediaPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/SharedMediaPreference.kt deleted file mode 100644 index 0cb408dabc..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/preferences/SharedMediaPreference.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.conversation.preferences - -import android.view.View -import com.bumptech.glide.Glide -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.ThreadPhotoRailView -import org.thoughtcrime.securesms.components.settings.PreferenceModel -import org.thoughtcrime.securesms.database.MediaTable -import org.thoughtcrime.securesms.util.ViewUtil -import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder - -/** - * Renders the shared media photo rail. - */ -object SharedMediaPreference { - - fun register(adapter: MappingAdapter) { - adapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.conversation_settings_shared_media)) - } - - class Model( - val mediaRecords: List, - val mediaIds: List, - val onMediaRecordClick: (View, MediaTable.MediaRecord, Boolean) -> Unit - ) : PreferenceModel() { - override fun areItemsTheSame(newItem: Model): Boolean { - return true - } - - override fun areContentsTheSame(newItem: Model): Boolean { - return super.areContentsTheSame(newItem) && - mediaIds == newItem.mediaIds - } - } - - private class ViewHolder(itemView: View) : MappingViewHolder(itemView) { - - private val rail: ThreadPhotoRailView = itemView.findViewById(R.id.rail_view) - - override fun bind(model: Model) { - rail.setMediaRecords(Glide.with(rail), model.mediaRecords) - rail.setListener { v, m -> - model.onMediaRecordClick(v, m, ViewUtil.isLtr(rail)) - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/BlockAndSpamHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/BlockAndSpamHandler.kt new file mode 100644 index 0000000000..666f925ba2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/BlockAndSpamHandler.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +import org.signal.core.util.Result +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsAction +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository +import org.thoughtcrime.securesms.database.model.StoryViewState +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * Blocking, unblocking, spam reporting, and avatar taps, which work the same way on every conversation type that offers + * them. Takes plain values rather than a shared state type, so each view model keeps its own state shape. + */ +object BlockAndSpamHandler { + + private val TAG = Log.tag(BlockAndSpamHandler::class) + + fun blockAction(recipient: Recipient): ConversationSettingsAction { + return if (recipient.isBlocked) { + ConversationSettingsAction.ShowUnblockDialog(recipient) + } else { + ConversationSettingsAction.ShowBlockDialog(recipient) + } + } + + fun reportSpamAction(recipient: Recipient): ConversationSettingsAction { + return ConversationSettingsAction.ShowReportSpamDialog(recipient, canBlock = !recipient.isBlocked) + } + + /** + * Where a tap on the header avatar should go. Every conversation type behaves the same way here: if there's a story to + * watch we ask which one the user meant, otherwise we go straight to the full-size avatar. + */ + fun avatarClickAction( + recipient: Recipient, + storyViewState: StoryViewState, + storiesEnabled: Boolean + ): ConversationSettingsAction? { + return when { + storiesEnabled && storyViewState != StoryViewState.NONE -> { + ConversationSettingsAction.ShowStoryOrAvatarDialog(recipient.id, recipient.shouldHideStory) + } + + !recipient.isSelf -> ConversationSettingsAction.ShowAvatarPreview(recipient.id) + + else -> null + } + } + + suspend fun reportSpam( + recipient: Recipient, + threadId: Long, + repository: ConversationSettingsRepository, + emitAction: suspend (ConversationSettingsAction) -> Unit + ) { + if (!canReportSpam(recipient, threadId)) { + Log.w(TAG, "[ReportSpam] Nothing to report yet, ignoring.") + return + } + + repository.reportSpam(recipient.id, threadId) + emitAction(ConversationSettingsAction.ShowSpamReported) + emitAction(ConversationSettingsAction.GoToConversationList) + } + + suspend fun blockAndReportSpam( + recipient: Recipient, + threadId: Long, + repository: ConversationSettingsRepository, + emitAction: suspend (ConversationSettingsAction) -> Unit + ) { + if (!canReportSpam(recipient, threadId)) { + Log.w(TAG, "[BlockAndReportSpam] Nothing to report yet, ignoring.") + return + } + + when (val result = repository.blockAndReportSpam(recipient.id, threadId)) { + is Result.Success -> { + emitAction(ConversationSettingsAction.ShowSpamReportedAndBlocked) + emitAction(ConversationSettingsAction.GoToConversationList) + } + + is Result.Failure -> emitAction(ConversationSettingsAction.ShowBlockError(result.failure)) + } + } + + private fun canReportSpam(recipient: Recipient, threadId: Long): Boolean { + return threadId > 0 && recipient != Recipient.UNKNOWN + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CallBar.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CallBar.kt new file mode 100644 index 0000000000..e632a5ee87 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CallBar.kt @@ -0,0 +1,327 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.DropdownMenus +import org.signal.core.ui.compose.Previews +import org.thoughtcrime.securesms.R +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +/** + * Which buttons the call bar under the header should offer. Not every conversation type can do every one of these -- + * note to self, for instance, only ever gets search. + */ +data class CallBarState( + val isMessageAvailable: Boolean = false, + val isVideoAvailable: Boolean = false, + val isAudioAvailable: Boolean = false, + val isAudioSecure: Boolean = false, + val isMuteAvailable: Boolean = false, + val isMuted: Boolean = false, + val isSearchAvailable: Boolean = false, + val isAddToStoryAvailable: Boolean = false +) + +/** + * The strip of story/message/call/mute/search buttons that sits under the header. + * + * Callbacks rather than a shared event type, so that each screen can route them into its own events. + */ +@Composable +fun CallBar( + state: CallBarState, + onAddToStoryClick: () -> Unit, + onMessageClick: () -> Unit, + onVideoCallClick: () -> Unit, + onAudioCallClick: () -> Unit, + onMuteClick: () -> Unit, + onMuteDurationSelected: (Long) -> Unit, + onMuteUntilCustomTimeClick: () -> Unit, + onMuteMenuDismissed: () -> Unit, + onSearchClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + isMuteMenuShown: Boolean = false +) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 24.dp, bottom = 16.dp) + ) { + if (state.isAddToStoryAvailable) { + Buttons.ActionButton( + onClick = onAddToStoryClick, + iconResId = R.drawable.add_to_story_24, + labelResId = R.string.ConversationSettingsFragment__story, + enabled = enabled + ) + } + + if (state.isMessageAvailable) { + Buttons.ActionButton( + onClick = onMessageClick, + iconResId = R.drawable.ic_chat_message_24, + labelResId = R.string.ConversationSettingsFragment__message, + enabled = enabled + ) + } + + if (state.isVideoAvailable) { + Buttons.ActionButton( + onClick = onVideoCallClick, + iconResId = R.drawable.ic_video_call_24, + labelResId = R.string.ConversationSettingsFragment__video, + enabled = enabled + ) + } + + if (state.isAudioAvailable) { + Buttons.ActionButton( + onClick = onAudioCallClick, + iconResId = if (state.isAudioSecure) R.drawable.ic_phone_right_24 else R.drawable.ic_phone_right_unlock_primary_accent_24, + labelResId = if (state.isAudioSecure) R.string.ConversationSettingsFragment__audio else R.string.ConversationSettingsFragment__call, + enabled = enabled + ) + } + + if (state.isMuteAvailable) { + MuteButton( + isMuted = state.isMuted, + isMenuShown = isMuteMenuShown, + enabled = enabled, + onClick = onMuteClick, + onDurationSelected = onMuteDurationSelected, + onCustomTimeClick = onMuteUntilCustomTimeClick, + onMenuDismissed = onMuteMenuDismissed + ) + } + + if (state.isSearchAvailable) { + Buttons.ActionButton( + onClick = onSearchClick, + iconResId = R.drawable.ic_search_24, + labelResId = R.string.ConversationSettingsFragment__search, + enabled = enabled + ) + } + } +} + +@Composable +private fun MuteButton( + isMuted: Boolean, + isMenuShown: Boolean, + enabled: Boolean, + onClick: () -> Unit, + onDurationSelected: (Long) -> Unit, + onCustomTimeClick: () -> Unit, + onMenuDismissed: () -> Unit, + modifier: Modifier = Modifier +) { + val controller = remember { DropdownMenus.MenuController() } + val controllerShown = controller.isShown() + + LaunchedEffect(isMenuShown) { + if (isMenuShown) { + controller.show() + } else { + controller.hide() + } + } + + // The menu hides itself when dismissed, so tell the view model to drop the dialog from state to match. + LaunchedEffect(controllerShown) { + if (!controllerShown && isMenuShown) { + onMenuDismissed() + } + } + + Box(modifier = modifier) { + Buttons.ActionButton( + onClick = onClick, + iconResId = if (isMuted) R.drawable.ic_bell_disabled_24 else R.drawable.ic_bell_24, + labelResId = if (isMuted) R.string.ConversationSettingsFragment__muted else R.string.ConversationSettingsFragment__mute, + enabled = enabled + ) + + DropdownMenus.Menu(controller = controller) { menuController -> + MUTE_DURATIONS.forEach { duration -> + DropdownMenus.ItemWithIcon( + menuController = menuController, + drawableResId = duration.iconResId, + stringResId = duration.labelResId, + onClick = { onDurationSelected(System.currentTimeMillis() + duration.durationMillis) } + ) + } + + DropdownMenus.ItemWithIcon( + menuController = menuController, + drawableResId = R.drawable.symbol_calendar_24, + stringResId = R.string.MuteDialog__mute_until, + onClick = onCustomTimeClick + ) + + DropdownMenus.ItemWithIcon( + menuController = menuController, + drawableResId = R.drawable.symbol_bell_slash_24, + stringResId = R.string.arrays__always, + onClick = { onDurationSelected(Long.MAX_VALUE) } + ) + } + } +} + +@Composable +private fun CallBarPreview(state: CallBarState, enabled: Boolean = true) { + Previews.Preview { + CallBar( + state = state, + enabled = enabled, + onAddToStoryClick = {}, + onMessageClick = {}, + onVideoCallClick = {}, + onAudioCallClick = {}, + onMuteClick = {}, + onMuteDurationSelected = {}, + onMuteUntilCustomTimeClick = {}, + onMuteMenuDismissed = {}, + onSearchClick = {} + ) + } +} + +/** What a 1:1 with a registered contact offers. */ +@DayNightPreviews +@Composable +private fun CallBarIndividualPreview() { + CallBarPreview( + CallBarState( + isVideoAvailable = true, + isAudioAvailable = true, + isAudioSecure = true, + isMuteAvailable = true, + isSearchAvailable = true + ) + ) +} + +/** Groups trade the audio call button for the add-to-story button. */ +@DayNightPreviews +@Composable +private fun CallBarGroupPreview() { + CallBarPreview( + CallBarState( + isVideoAvailable = true, + isMuteAvailable = true, + isSearchAvailable = true, + isAddToStoryAvailable = true + ) + ) +} + +/** Note to self, which has nobody to call or mute. */ +@DayNightPreviews +@Composable +private fun CallBarNoteToSelfPreview() { + CallBarPreview(CallBarState(isSearchAvailable = true)) +} + +/** The release notes chat, which can be muted but not called. */ +@DayNightPreviews +@Composable +private fun CallBarReleaseNotesPreview() { + CallBarPreview(CallBarState(isMuteAvailable = true, isSearchAvailable = true)) +} + +@DayNightPreviews +@Composable +private fun CallBarMutedPreview() { + CallBarPreview( + CallBarState( + isVideoAvailable = true, + isAudioAvailable = true, + isAudioSecure = true, + isMuteAvailable = true, + isMuted = true, + isSearchAvailable = true + ) + ) +} + +/** An unregistered peer, where the audio button falls back to an insecure "Call" that dials out. */ +@DayNightPreviews +@Composable +private fun CallBarInsecureAudioPreview() { + CallBarPreview( + CallBarState( + isAudioAvailable = true, + isAudioSecure = false, + isMuteAvailable = true, + isSearchAvailable = true + ) + ) +} + +/** Opened as call info, where the message button takes the place of search. */ +@DayNightPreviews +@Composable +private fun CallBarCallInfoPreview() { + CallBarPreview( + CallBarState( + isMessageAvailable = true, + isVideoAvailable = true, + isAudioAvailable = true, + isAudioSecure = true, + isMuteAvailable = true + ) + ) +} + +@DayNightPreviews +@Composable +private fun CallBarDisabledPreview() { + CallBarPreview( + CallBarState( + isVideoAvailable = true, + isAudioAvailable = true, + isAudioSecure = true, + isMuteAvailable = true, + isSearchAvailable = true + ), + enabled = false + ) +} + +private data class MuteDuration( + @DrawableRes val iconResId: Int, + @StringRes val labelResId: Int, + val durationMillis: Long +) + +private val MUTE_DURATIONS = listOf( + MuteDuration(R.drawable.ic_daytime_24, R.string.arrays__mute_for_one_hour, 1.hours.inWholeMilliseconds), + MuteDuration(R.drawable.ic_nighttime_26, R.string.arrays__mute_for_eight_hours, 8.hours.inWholeMilliseconds), + MuteDuration(R.drawable.symbol_calendar_one, R.string.arrays__mute_for_one_day, 1.days.inWholeMilliseconds), + MuteDuration(R.drawable.symbol_calendar_week, R.string.arrays__mute_for_seven_days, 7.days.inWholeMilliseconds) +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CallLogSection.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CallLogSection.kt new file mode 100644 index 0000000000..ddb593f9dd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CallLogSection.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLocale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Rows.TextAndLabel +import org.signal.core.ui.compose.Texts +import org.thoughtcrime.securesms.components.settings.conversation.CallRowResources +import org.thoughtcrime.securesms.database.CallTable +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.util.DateUtils + +/** A single call in the call-info variant of a conversation settings screen. */ +data class CallEntry( + val call: CallTable.Call, + val record: MessageRecord +) + +/** + * The list of calls shown when the screen is opened as call info. Only individual and group conversations have calls, + * so only those two screens include this section. + */ +fun LazyListScope.callLogSection(calls: List) { + if (calls.isEmpty()) { + return + } + + item { + Texts.SectionHeader(text = DateUtils.formatDate(LocalLocale.current.platformLocale, calls.first().record.timestamp)) + } + + items( + items = calls, + key = { it.record.id } + ) { entry -> + CallRow(entry = entry) + } + + item { Dividers.Default() } +} + +@Composable +private fun CallRow( + entry: CallEntry, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + Rows.TextRow( + text = { + TextAndLabel( + text = stringResource(CallRowResources.typeStringRes(entry.call)), + label = DateUtils.getOnlyTimeString(context, entry.record.timestamp) + ) + }, + icon = { + Icon( + painter = painterResource(CallRowResources.iconRes(entry.call)), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface + ) + }, + modifier = modifier + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CollapsibleList.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CollapsibleList.kt new file mode 100644 index 0000000000..7f8327a179 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CollapsibleList.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +/** + * The lists on the settings screens that start out truncated behind a "see all" row -- group members, and groups in + * common. + * + * We only collapse past [COLLAPSE_THRESHOLD] so that hiding a single entry is never the reason for the extra tap. + */ +object CollapsibleList { + private const val COLLAPSE_THRESHOLD = 6 + private const val COLLAPSED_COUNT = 5 + + fun canExpand(all: List<*>, expanded: Boolean): Boolean = !expanded && all.size > COLLAPSE_THRESHOLD + + fun collapse(all: List, expanded: Boolean): List { + return if (canExpand(all, expanded)) all.take(COLLAPSED_COUNT) else all + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CommonSettingsRows.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CommonSettingsRows.kt new file mode 100644 index 0000000000..9755522bc0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/CommonSettingsRows.kt @@ -0,0 +1,205 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * Setting rows that read and behave identically wherever they appear. Which of them a conversation actually offers is + * up to each screen -- note to self has no notification settings, release notes has no wallpaper, and so on. + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +import android.content.Context +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.ExpirationUtil +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import org.signal.core.ui.R as CoreUiR + +@Composable +fun DisappearingMessagesRow( + lifespanSeconds: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + val context = LocalContext.current + val icon = if (lifespanSeconds <= 0) R.drawable.symbol_timer_slash_24 else R.drawable.symbol_timer_24 + + Rows.TextRow( + text = stringResource(R.string.ConversationSettingsFragment__disappearing_messages), + label = formatDisappearingMessagesLifespan(context, lifespanSeconds), + icon = painterResource(icon), + enabled = enabled, + onClick = onClick, + modifier = modifier + ) +} + +@Composable +fun ChatColorAndWallpaperRow( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Rows.TextRow( + text = stringResource(R.string.preferences__chat_color_and_wallpaper), + icon = painterResource(R.drawable.symbol_color_24), + onClick = onClick, + modifier = modifier + ) +} + +@Composable +fun SoundsAndNotificationsRow( + onClick: () -> Unit, + modifier: Modifier = Modifier, + isInternalUser: Boolean = false, + enabled: Boolean = true +) { + val label = stringResource(R.string.ConversationSettingsFragment__sounds_and_notifications) + + Rows.TextRow( + text = if (isInternalUser) "$label (Internal Only)" else label, + icon = painterResource(R.drawable.symbol_speaker_24), + enabled = enabled, + onClick = onClick, + modifier = modifier + ) +} + +@Composable +fun StarredMessagesRow( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Rows.TextRow( + text = stringResource(R.string.ConversationSettingsFragment__starred_messages), + icon = painterResource(R.drawable.symbol_star_outline_24), + onClick = onClick, + modifier = modifier + ) +} + +@Composable +fun BlockRow( + isBlocked: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isGroup: Boolean = false, + enabled: Boolean = true +) { + val titleRes = when { + isBlocked && isGroup -> R.string.ConversationSettingsFragment__unblock_group + isBlocked -> R.string.ConversationSettingsFragment__unblock + isGroup -> R.string.ConversationSettingsFragment__block_group + else -> R.string.ConversationSettingsFragment__block + } + + Rows.TextRow( + text = stringResource(titleRes), + icon = painterResource(R.drawable.symbol_block_24), + foregroundTint = if (isBlocked) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.error, + enabled = enabled, + onClick = onClick, + modifier = modifier + ) +} + +@Composable +fun ReportSpamRow( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + Rows.TextRow( + text = stringResource(R.string.ConversationFragment_report_spam), + icon = painterResource(R.drawable.symbol_spam_24), + foregroundTint = MaterialTheme.colorScheme.error, + enabled = enabled, + onClick = onClick, + modifier = modifier + ) +} + +@Composable +fun ArchiveChatRow( + isArchived: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Rows.TextRow( + text = stringResource(if (isArchived) R.string.ConversationListFragment_unarchive else R.string.ConversationSettingsFragment__archive_chat), + icon = painterResource(if (isArchived) R.drawable.symbol_archive_up_24 else R.drawable.symbol_archive_24), + onClick = onClick, + modifier = modifier + ) +} + +@Composable +fun DeleteChatRow( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Rows.TextRow( + text = stringResource(R.string.ConversationSettingsFragment__delete_chat), + icon = painterResource(CoreUiR.drawable.symbol_trash_24), + foregroundTint = MaterialTheme.colorScheme.error, + onClick = onClick, + modifier = modifier + ) +} + +private fun formatDisappearingMessagesLifespan(context: Context, lifespanSeconds: Int): String { + return if (lifespanSeconds <= 0) { + context.getString(R.string.preferences_off) + } else { + ExpirationUtil.getExpirationDisplayValue(context, lifespanSeconds) + } +} + +/** The rows that adjust how a chat behaves. */ +@DayNightPreviews +@Composable +private fun ChatSettingsRowsPreview() { + Previews.Preview { + Column { + DisappearingMessagesRow(lifespanSeconds = 0, onClick = {}) + DisappearingMessagesRow(lifespanSeconds = 4.hours.inWholeSeconds.toInt(), onClick = {}) + DisappearingMessagesRow(lifespanSeconds = 7.days.inWholeSeconds.toInt(), enabled = false, onClick = {}) + ChatColorAndWallpaperRow(onClick = {}) + SoundsAndNotificationsRow(onClick = {}) + SoundsAndNotificationsRow(isInternalUser = true, onClick = {}) + SoundsAndNotificationsRow(enabled = false, onClick = {}) + StarredMessagesRow(onClick = {}) + } + } +} + +/** The rows at the bottom of the screen, which are destructive and tinted to say so. */ +@DayNightPreviews +@Composable +private fun DestructiveSettingsRowsPreview() { + Previews.Preview { + Column { + BlockRow(isBlocked = false, onClick = {}) + BlockRow(isBlocked = true, onClick = {}) + BlockRow(isBlocked = false, isGroup = true, onClick = {}) + BlockRow(isBlocked = true, isGroup = true, onClick = {}) + ReportSpamRow(onClick = {}) + ArchiveChatRow(isArchived = false, onClick = {}) + ArchiveChatRow(isArchived = true, onClick = {}) + DeleteChatRow(onClick = {}) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/ConversationHeader.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/ConversationHeader.kt new file mode 100644 index 0000000000..8934976480 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/ConversationHeader.kt @@ -0,0 +1,339 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +import android.view.View +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.AvatarImage +import org.thoughtcrime.securesms.avatar.view.AvatarView +import org.thoughtcrime.securesms.badges.BadgeImageView +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.components.emoji.EmojiText +import org.thoughtcrime.securesms.database.model.StoryViewState +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.Recipient +import org.signal.core.ui.R as CoreUiR + +private val AVATAR_SIZE = 80.dp +private val BADGE_SIZE = 36.dp +private val BADGE_OFFSET_X = 44.dp +private val BADGE_OFFSET_Y = 52.dp +private val VERIFIED_BADGE_SIZE = 28.dp +private val HEADLINE_GLYPH_SIZE = 24.dp + +/** + * The avatar, name, and one-line subhead that every conversation settings screen opens with. + * + * [underName] lets a screen slot something between the name and the subhead -- the group screen uses it for its + * "this group was ended" pill. + */ +@Composable +fun ConversationHeader( + recipient: Recipient, + name: String, + modifier: Modifier = Modifier, + storyViewState: StoryViewState = StoryViewState.NONE, + subhead: String? = null, + showVerifiedBadge: Boolean = false, + showSystemContactBadge: Boolean = false, + badges: List = emptyList(), + onAvatarClick: () -> Unit = {}, + onBadgeClick: (Badge) -> Unit = {}, + onNameClick: (() -> Unit)? = null, + onAvatarViewCreated: (View) -> Unit = {}, + underName: @Composable ColumnScope.() -> Unit = {} +) { + Column(modifier = modifier) { + AvatarHeader( + recipient = recipient, + storyViewState = storyViewState, + badges = badges, + onAvatarClick = onAvatarClick, + onBadgeClick = onBadgeClick, + onAvatarViewCreated = onAvatarViewCreated + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + .padding(top = 4.dp) + ) { + ConversationHeadline( + name = name, + showVerifiedBadge = showVerifiedBadge, + showSystemContactBadge = showSystemContactBadge, + onClick = onNameClick + ) + + underName() + + if (!subhead.isNullOrBlank()) { + EmojiText( + text = subhead, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + } +} + +@Composable +private fun AvatarHeader( + recipient: Recipient, + storyViewState: StoryViewState, + badges: List, + onAvatarClick: () -> Unit, + onBadgeClick: (Badge) -> Unit, + onAvatarViewCreated: (View) -> Unit, + modifier: Modifier = Modifier +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) { + // Sized to the avatar rather than the header, so the badge can hang off of the avatar's bottom-end corner. + Box( + modifier = Modifier + .width(AVATAR_SIZE) + .height(if (badges.isNotEmpty()) BADGE_OFFSET_Y + BADGE_SIZE else AVATAR_SIZE) + ) { + if (LocalInspectionMode.current) { + AvatarImage( + recipient = recipient, + useProfile = false, + modifier = Modifier.size(AVATAR_SIZE) + ) + } else { + // A real View, so the jump to the avatar preview has something to run its shared element transition out of. + AndroidView( + factory = { context -> + AvatarView(context).also(onAvatarViewCreated).apply { disableQuickContact() } + }, + modifier = Modifier.size(AVATAR_SIZE) + ) { avatarView -> + avatarView.setStoryRingFromState(storyViewState) + avatarView.displayChatAvatar(recipient) + avatarView.setOnClickListener { onAvatarClick() } + } + } + + if (badges.isNotEmpty()) { + AndroidView( + factory = { context -> BadgeImageView(context, null) }, + modifier = Modifier + .offset(x = BADGE_OFFSET_X, y = BADGE_OFFSET_Y) + .size(BADGE_SIZE) + ) { badgeView -> + badgeView.setBadgeFromRecipient(recipient) + badgeView.setOnClickListener { onBadgeClick(badges.first()) } + } + } + } + } +} + +/** + * The conversation's name, plus the decorations the old `Recipient.getDisplayNameForHeadline` baked into a spanned + * string. They're drawn as real icons here rather than SignalSymbols font glyphs, which only render inside a TextView + * that has the symbols typeface applied. + */ +@Composable +private fun ConversationHeadline( + name: String, + showVerifiedBadge: Boolean, + showSystemContactBadge: Boolean, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 4.dp) + ) { + EmojiText( + text = name, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f, fill = false) + ) + + if (showVerifiedBadge) { + Image( + painter = painterResource(R.drawable.ic_official_28), + contentDescription = null, + modifier = Modifier + .padding(start = 8.dp) + .size(VERIFIED_BADGE_SIZE) + ) + } else if (showSystemContactBadge) { + Icon( + painter = painterResource(CoreUiR.drawable.symbol_person_circle_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(start = 4.dp) + .size(HEADLINE_GLYPH_SIZE) + ) + } + + if (onClick != null) { + Icon( + painter = painterResource(CoreUiR.drawable.symbol_chevron_right_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier + .padding(start = 4.dp) + .size(HEADLINE_GLYPH_SIZE) + ) + } + } +} + +@Composable +fun InternalDetailsButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) { + Buttons.MediumTonal(onClick = onClick) { + Text(text = stringResource(R.string.preferences__internal_details)) + } + } +} + +/** A 1:1 with another person: about line for a subhead, and a tappable name that opens their profile. */ +@DayNightPreviews +@Composable +private fun ConversationHeaderIndividualPreview() { + Previews.Preview { + ConversationHeader( + recipient = previewRecipient(1L, profileName = ProfileName.fromParts("Miles", "Morales"), about = "Just hanging around"), + name = "Miles Morales", + subhead = "Just hanging around", + onNameClick = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun ConversationHeaderSystemContactPreview() { + Previews.Preview { + ConversationHeader( + recipient = previewRecipient(1L, profileName = ProfileName.fromParts("Gwen", "Stacy")), + name = "Gwen Stacy", + showSystemContactBadge = true, + onNameClick = {} + ) + } +} + +/** Note to self and the release notes chat both get the official checkmark, and neither name is tappable. */ +@DayNightPreviews +@Composable +private fun ConversationHeaderVerifiedPreview() { + Previews.Preview { + ConversationHeader( + recipient = previewRecipient(1L, isSelf = true), + name = "Note to Self", + showVerifiedBadge = true + ) + } +} + +/** A group, whose subhead is its member count. */ +@DayNightPreviews +@Composable +private fun ConversationHeaderGroupPreview() { + Previews.Preview { + ConversationHeader( + recipient = previewRecipient(1L, groupName = "Deep Space Nine"), + name = "Deep Space Nine", + subhead = "2 members" + ) + } +} + +/** An ended group, which uses [ConversationHeader]'s slot to explain itself between the name and the subhead. */ +@DayNightPreviews +@Composable +private fun ConversationHeaderWithSlotPreview() { + Previews.Preview { + ConversationHeader( + recipient = previewRecipient(1L, groupName = "Terok Nor"), + name = "Terok Nor", + subhead = "2 members" + ) { + Text( + text = "This group was ended", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .padding(top = 8.dp) + .background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(percent = 50)) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } +} + +/** A long name, which the headline wraps and centers rather than truncating on one line. */ +@DayNightPreviews +@Composable +private fun ConversationHeaderLongNamePreview() { + Previews.Preview { + ConversationHeader( + recipient = previewRecipient(1L, groupName = "Bajoran Provisional Government Liaison Committee"), + name = "Bajoran Provisional Government Liaison Committee", + subhead = "47 members" + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/ConversationSettingsRows.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/ConversationSettingsRows.kt new file mode 100644 index 0000000000..2bdc586aed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/ConversationSettingsRows.kt @@ -0,0 +1,137 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Rows.TextAndLabel +import org.signal.core.ui.compose.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.AvatarImage +import org.thoughtcrime.securesms.components.emoji.EmojiText +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.Recipient + +private val LARGE_ICON_SIZE = 40.dp + +internal val ROW_AVATAR_SIZE = 40.dp + +/** A row with an icon inside a large circle, used for the "add member" and "see all" affordances. */ +@Composable +fun LargeIconRow( + text: String, + @DrawableRes icon: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + Rows.TextRow( + text = { TextAndLabel(text = text, enabled = enabled) }, + icon = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(LARGE_ICON_SIZE) + .alpha(if (enabled) 1f else Rows.DISABLED_ALPHA) + .background(color = SignalTheme.colors.colorSurface1, shape = CircleShape) + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface + ) + } + }, + onClick = onClick, + enabled = enabled, + modifier = modifier + ) +} + +/** A row that names a recipient, with their avatar and about line. */ +@Composable +fun RecipientRow( + recipient: Recipient, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val about = recipient.combinedAboutAndEmoji + + Rows.TextRow( + text = { + Column(modifier = Modifier.weight(1f)) { + EmojiText( + text = recipient.getDisplayName(context), + style = MaterialTheme.typography.bodyLarge + ) + + if (!about.isNullOrBlank()) { + EmojiText( + text = about, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + } + }, + icon = { + AvatarImage( + recipient = recipient, + modifier = Modifier.size(ROW_AVATAR_SIZE) + ) + }, + onClick = onClick, + modifier = modifier + ) +} + +@DayNightPreviews +@Composable +private fun LargeIconRowPreview() { + Previews.Preview { + Column { + LargeIconRow(text = "Add members", icon = R.drawable.ic_plus_24, onClick = {}) + LargeIconRow(text = "See all", icon = R.drawable.ic_chevron_down_icon_20, onClick = {}) + LargeIconRow(text = "Add to a group", icon = R.drawable.ic_plus_24, enabled = false, onClick = {}) + } + } +} + +@DayNightPreviews +@Composable +private fun RecipientRowPreview() { + Previews.Preview { + Column { + RecipientRow( + recipient = previewRecipient(1L, profileName = ProfileName.fromParts("Kathryn", "Janeway"), about = "Coffee, black"), + onClick = {} + ) + + RecipientRow( + recipient = previewRecipient(2L, groupName = "Delta Quadrant"), + onClick = {} + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/ConversationSettingsScaffold.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/ConversationSettingsScaffold.kt new file mode 100644 index 0000000000..79302672c9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/ConversationSettingsScaffold.kt @@ -0,0 +1,186 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalIcons +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.AvatarImage +import org.thoughtcrime.securesms.components.emoji.EmojiText +import org.thoughtcrime.securesms.recipients.Recipient +import org.signal.core.ui.R as CoreUiR + +private val TOOLBAR_AVATAR_SIZE = 32.dp + +/** + * The frame that every conversation settings screen sits in, whoever the conversation is with: a settings toolbar + * whose avatar and title fade in once the header has scrolled away, wrapped around a lazy list of setting rows. + */ +@Composable +fun ConversationSettingsScaffold( + title: String, + recipient: Recipient, + onNavigationClick: () -> Unit, + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = {}, + content: LazyListScope.() -> Unit +) { + val listState = rememberLazyListState() + val showToolbarDetails by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } + val toolbarAlpha by animateFloatAsState(targetValue = if (showToolbarDetails) 1f else 0f, label = "toolbar-alpha") + + Scaffolds.Settings( + title = title, + onNavigationClick = onNavigationClick, + navigationIcon = SignalIcons.ArrowStart.imageVector, + navigationContentDescription = stringResource(R.string.CallScreenTopBar__go_back), + titleContent = { _, toolbarTitle -> + ToolbarTitle( + title = toolbarTitle, + recipient = recipient, + alpha = toolbarAlpha + ) + }, + actions = actions, + modifier = modifier + ) { paddingValues -> + LazyColumn( + state = listState, + modifier = Modifier.padding(paddingValues), + content = content + ) + } +} + +/** + * The scaffold at rest, which is how it looks before the list has been scrolled. The toolbar's own avatar and title are + * faded out at this point, since the header below is already showing them -- see [ToolbarTitleScrolledPreview]. + */ +@Composable +private fun ToolbarTitle( + title: String, + recipient: Recipient, + alpha: Float, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.alpha(alpha) + ) { + if (recipient != Recipient.UNKNOWN) { + AvatarImage( + recipient = recipient, + useProfile = false, + modifier = Modifier.size(TOOLBAR_AVATAR_SIZE) + ) + + Spacer(modifier = Modifier.width(12.dp)) + } + + EmojiText( + text = title, + style = MaterialTheme.typography.titleLarge, + maxLines = 1 + ) + } +} + +private val SCAFFOLD_PREVIEW_ROWS = listOf( + "Disappearing messages", + "Chat color & wallpaper", + "Sounds & notifications", + "Starred messages" +) + +@DayNightPreviews +@Composable +private fun ConversationSettingsScaffoldPreview() { + Previews.Preview { + ConversationSettingsScaffold( + title = "Deep Space Nine", + recipient = previewRecipient(1L, groupName = "Deep Space Nine"), + onNavigationClick = {}, + actions = { + IconButton(onClick = {}) { + Icon( + painter = painterResource(CoreUiR.drawable.symbol_edit_24), + contentDescription = null + ) + } + } + ) { + item { + ConversationHeader( + recipient = previewRecipient(1L, groupName = "Deep Space Nine"), + name = "Deep Space Nine", + subhead = "2 members" + ) + } + + item { + CallBar( + state = CallBarState(isVideoAvailable = true, isMuteAvailable = true, isSearchAvailable = true), + onAddToStoryClick = {}, + onMessageClick = {}, + onVideoCallClick = {}, + onAudioCallClick = {}, + onMuteClick = {}, + onMuteDurationSelected = {}, + onMuteUntilCustomTimeClick = {}, + onMuteMenuDismissed = {}, + onSearchClick = {} + ) + } + + item { Dividers.Default() } + + items(SCAFFOLD_PREVIEW_ROWS) { row -> + Rows.TextRow(text = row, onClick = {}) + } + } + } +} + +/** What the toolbar looks like once the header has scrolled away and it has faded in. */ +@DayNightPreviews +@Composable +private fun ToolbarTitleScrolledPreview() { + Previews.Preview { + ToolbarTitle( + title = "Deep Space Nine", + recipient = previewRecipient(1L, groupName = "Deep Space Nine"), + alpha = 1f + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/GroupMember.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/GroupMember.kt new file mode 100644 index 0000000000..7bd091a879 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/GroupMember.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +import org.thoughtcrime.securesms.recipients.Recipient + +/** A member of the group, flattened into something the screen can render directly. */ +data class GroupMember( + val recipient: Recipient, + val isAdmin: Boolean +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/LegacyGroupState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/LegacyGroupState.kt new file mode 100644 index 0000000000..acc645b404 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/LegacyGroupState.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +/** Whether an old-style group needs an explanation, an invite nudge, or neither. */ +enum class LegacyGroupState { + LEARN_MORE, + MMS_WARNING, + NONE +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/PreviewRecipients.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/PreviewRecipients.kt new file mode 100644 index 0000000000..d1d9445007 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/PreviewRecipients.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * A recipient that can render in a preview. It needs an id, or it'd be equal to [Recipient.UNKNOWN] and the screen + * would render as empty, and it needs to already be resolved, since resolving one reads the database. + */ +fun previewRecipient( + id: Long, + profileName: ProfileName = ProfileName.EMPTY, + about: String? = null, + groupName: String? = null, + groupId: GroupId? = null, + isSelf: Boolean = false, + isReleaseNotes: Boolean = false, + isBlocked: Boolean = false +): Recipient { + return Recipient( + id = RecipientId.from(id), + isResolving = false, + profileName = profileName, + about = about, + groupName = groupName, + groupIdValue = groupId, + isActiveGroup = groupId != null, + isSelf = isSelf, + isReleaseNotes = isReleaseNotes, + isBlocked = isBlocked + ) +} + +/** + * Built by parsing an encoded id rather than deriving one from a GroupMasterKey, since deriving needs libsignal's + * native library and the preview renderer isn't allowed to load it. + */ +val PREVIEW_GROUP_ID: GroupId by lazy { GroupId.parseOrThrow("__signal_group__v2__!" + "01".repeat(32)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaClickAction.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaClickAction.kt new file mode 100644 index 0000000000..988d1e8836 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaClickAction.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsAction +import org.thoughtcrime.securesms.database.AttachmentTable +import org.thoughtcrime.securesms.database.MediaTable + +/** Where a tap on the shared media rail should go, which depends on whether the attachment is actually here yet. */ +fun sharedMediaClickAction(mediaRecord: MediaTable.MediaRecord, isLtr: Boolean): ConversationSettingsAction { + val attachment = mediaRecord.attachment + + return when { + attachment == null -> { + ConversationSettingsAction.ShowMediaNotSentYet + } + attachment.displayUri == null -> { + if (attachment.transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED) { + ConversationSettingsAction.DownloadMedia(mediaRecord) + } else { + ConversationSettingsAction.ShowMediaNotSentYet + } + } + attachment.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && + attachment.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED -> { + ConversationSettingsAction.ShowMediaNotSentYet + } + else -> { + ConversationSettingsAction.ShowMediaPreview(mediaRecord, isLtr) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaLoader.kt new file mode 100644 index 0000000000..a95d34a90a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaLoader.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository +import org.thoughtcrime.securesms.database.MediaTable + +private const val SHARED_MEDIA_LIMIT = 100 + +/** + * Loads the shared media rail for a thread, and lets the host ask for a reload after the user comes back from the + * media viewer. Held by each conversation settings view model, which applies the results to its own state. + * + * Nothing is emitted until the thread id has been resolved, so that a screen can tell "still loading" apart from + * "this chat has no media" and reserve space for the rail accordingly. + */ +class SharedMediaLoader(private val repository: ConversationSettingsRepository) { + + private val refreshTrigger = MutableSharedFlow(replay = 1).apply { tryEmit(Unit) } + private val threadId = MutableStateFlow(null) + + fun onThreadIdLoaded(threadId: Long) { + this.threadId.value = threadId + } + + fun refresh() { + refreshTrigger.tryEmit(Unit) + } + + fun observe(): Flow> { + return combine(threadId.filterNotNull().distinctUntilChanged(), refreshTrigger) { id, _ -> id } + .map { repository.getSharedMedia(it, SHARED_MEDIA_LIMIT) } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaSection.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaSection.kt new file mode 100644 index 0000000000..9dc42caa44 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/SharedMediaSection.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +import android.view.View +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.bumptech.glide.Glide +import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Texts +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ThreadPhotoRailView +import org.thoughtcrime.securesms.database.MediaTable + +private val RAIL_HEIGHT = 80.dp + +/** + * The shared media rail and its "see all" row, which every conversation type shows. + * + * The rail is a fixed height and stays in the layout while [loaded] is false, so that media arriving later fills in + * space that was already there instead of shoving the rest of the screen down. It's only dropped once we know the chat + * has no media at all. + */ +fun LazyListScope.sharedMediaSection( + media: List, + loaded: Boolean, + onMediaClick: (MediaTable.MediaRecord, Boolean) -> Unit, + onMediaViewClicked: (View) -> Unit, + onSeeAllClick: () -> Unit +) { + if (loaded && media.isEmpty()) { + return + } + + item { Dividers.Default() } + + item { Texts.SectionHeader(text = stringResource(R.string.recipient_preference_activity__shared_media)) } + + item { + SharedMediaRail( + media = media, + onMediaClick = onMediaClick, + onMediaViewClicked = onMediaViewClicked + ) + } + + item { + Rows.TextRow( + text = stringResource(R.string.ConversationSettingsFragment__see_all), + onClick = onSeeAllClick + ) + } +} + +@Composable +private fun SharedMediaRail( + media: List, + onMediaClick: (MediaTable.MediaRecord, Boolean) -> Unit, + onMediaViewClicked: (View) -> Unit, + modifier: Modifier = Modifier +) { + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + + AndroidView( + factory = { context -> ThreadPhotoRailView(context) }, + modifier = modifier + .fillMaxWidth() + .height(RAIL_HEIGHT) + ) { railView -> + railView.setListener { view, mediaRecord -> + onMediaViewClicked(view) + onMediaClick(mediaRecord, isLtr) + } + railView.setMediaRecords(Glide.with(railView), media) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/UnmuteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/UnmuteDialog.kt new file mode 100644 index 0000000000..dac4f3c447 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/shared/UnmuteDialog.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.shared + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import org.signal.core.ui.compose.Dialogs +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil +import org.thoughtcrime.securesms.recipients.Recipient + +/** Asks the user to confirm unmuting a chat, telling them how long it would otherwise stay muted. */ +@Composable +fun UnmuteDialog( + recipient: Recipient, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + + Dialogs.SimpleAlertDialog( + title = "", + body = recipient.muteUntil.formatMutedUntil(context), + confirm = stringResource(R.string.ConversationSettingsFragment__unmute), + dismiss = stringResource(android.R.string.cancel), + onConfirm = onConfirm, + onDismiss = onDismiss + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt index 2f428a0d88..6b5407b7ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt @@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavHostFragment +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavHostFragment.Companion.setConversationSettingsAnimations import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner import org.thoughtcrime.securesms.conversation.ConversationIntents @@ -153,6 +154,7 @@ open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaCo val args = ConversationSettingsNavHostFragment.createArgs(location.recipientId) supportFragmentManager .beginTransaction() + .setConversationSettingsAnimations() .replace(R.id.fragment_container, ConversationSettingsNavHostFragment::class.java, args) .addToBackStack(null) .commit() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt index 8cae3236be..5d6d682c89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupRecord.kt @@ -116,6 +116,22 @@ class GroupRecord( } } + /** Whether this group's invite link exists and is currently open to anyone holding it. */ + val isGroupLinkEnabled: Boolean + get() { + if (!hasV2GroupProperties) { + return false + } + + val decryptedGroup = requireV2GroupProperties().decryptedGroup + if (decryptedGroup.inviteLinkPassword.size == 0) { + return false + } + + val addFromInviteLink = decryptedGroup.accessControl?.addFromInviteLink ?: return false + return addFromInviteLink == AccessControl.AccessRequired.ANY || addFromInviteLink == AccessControl.AccessRequired.ADMINISTRATOR + } + /** Who is allowed to modify the attributes of this group, name/avatar/timer etc. */ val attributesAccessControl: GroupAccessControl get() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java index 1baeef636c..73e2b42c87 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java @@ -62,11 +62,12 @@ public final class LiveGroup { this.requestingMembers = mapToRequestingMembers(this.groupRecord); if (groupId.isV2()) { - LiveData v2Properties = Transformations.map(this.groupRecord, g -> g.getHasV2GroupProperties() ? g.requireV2GroupProperties() : null); - this.groupLink = Transformations.map(v2Properties, g -> { - if (g == null) { + this.groupLink = Transformations.map(this.groupRecord, record -> { + if (!record.getHasV2GroupProperties()) { return GroupLinkUrlAndStatus.NONE; } + + GroupTable.V2GroupProperties g = record.requireV2GroupProperties(); DecryptedGroup group = g.getDecryptedGroup(); AccessControl.AccessRequired addFromInviteLink = group.accessControl != null ? group.accessControl.addFromInviteLink : new AccessControl().addFromInviteLink; @@ -74,12 +75,11 @@ public final class LiveGroup { return GroupLinkUrlAndStatus.NONE; } - boolean enabled = addFromInviteLink == AccessControl.AccessRequired.ANY || addFromInviteLink == AccessControl.AccessRequired.ADMINISTRATOR; boolean adminApproval = addFromInviteLink == AccessControl.AccessRequired.ADMINISTRATOR; String url = GroupInviteLinkUrl.forGroup(g.getGroupMasterKey(), group) .getUrl(); - return new GroupLinkUrlAndStatus(enabled, adminApproval, url); + return new GroupLinkUrlAndStatus(record.isGroupLinkEnabled(), adminApproval, url); }); } else { this.groupLink = new MutableLiveData<>(GroupLinkUrlAndStatus.NONE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.kt index 4d9dd94405..c166b38e8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.kt @@ -41,7 +41,7 @@ import org.signal.core.util.nullIfBlank import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.PushContactSelectionActivity import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsEvent +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsAction import org.thoughtcrime.securesms.contacts.SelectedContact import org.thoughtcrime.securesms.database.model.GroupRecord import org.thoughtcrime.securesms.groups.GroupId @@ -68,12 +68,12 @@ class AddMembersActivity : PassphraseRequiredActivity() { fun createIntent( context: Context, - event: ConversationSettingsEvent.AddMembersToGroup + action: ConversationSettingsAction.AddMembersToGroup ): Intent { return Intent(context, AddMembersActivity::class.java).apply { - putExtra(EXTRA_GROUP_ID, event.groupId) - putExtra(EXTRA_SELECTION_LIMITS, event.selectionLimits) - putParcelableArrayListExtra(EXTRA_PRESELECTED_RECIPIENTS, ArrayList(event.groupMembersWithoutSelf)) + putExtra(EXTRA_GROUP_ID, action.groupId) + putExtra(EXTRA_SELECTION_LIMITS, action.selectionLimits) + putParcelableArrayListExtra(EXTRA_PRESELECTED_RECIPIENTS, ArrayList(action.groupMembersWithoutSelf)) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.kt index 357af8d2c5..e153b5eafa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.kt @@ -4,7 +4,6 @@ */ package org.thoughtcrime.securesms.jobs -import androidx.annotation.MainThread import okio.Source import okio.buffer import org.greenrobot.eventbus.EventBus @@ -92,7 +91,6 @@ class AttachmentDownloadJob private constructor( } @JvmStatic - @MainThread fun downloadAttachmentIfNeeded(databaseAttachment: DatabaseAttachment): String? { return when (val transferState = databaseAttachment.transferState) { AttachmentTable.TRANSFER_PROGRESS_DONE -> null diff --git a/app/src/main/res/layout/conversation_settings_avatar_preference_item.xml b/app/src/main/res/layout/conversation_settings_avatar_preference_item.xml deleted file mode 100644 index c74d98893a..0000000000 --- a/app/src/main/res/layout/conversation_settings_avatar_preference_item.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_bio_preference_item.xml b/app/src/main/res/layout/conversation_settings_bio_preference_item.xml deleted file mode 100644 index 5fdc686a50..0000000000 --- a/app/src/main/res/layout/conversation_settings_bio_preference_item.xml +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_call_preference_item.xml b/app/src/main/res/layout/conversation_settings_call_preference_item.xml deleted file mode 100644 index af6aab9834..0000000000 --- a/app/src/main/res/layout/conversation_settings_call_preference_item.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_fragment.xml b/app/src/main/res/layout/conversation_settings_fragment.xml deleted file mode 100644 index e74f5f7cdb..0000000000 --- a/app/src/main/res/layout/conversation_settings_fragment.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_group_description_preference.xml b/app/src/main/res/layout/conversation_settings_group_description_preference.xml deleted file mode 100644 index d6f95ac24f..0000000000 --- a/app/src/main/res/layout/conversation_settings_group_description_preference.xml +++ /dev/null @@ -1,16 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_legacy_group_preference.xml b/app/src/main/res/layout/conversation_settings_legacy_group_preference.xml deleted file mode 100644 index eb75662664..0000000000 --- a/app/src/main/res/layout/conversation_settings_legacy_group_preference.xml +++ /dev/null @@ -1,14 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_shared_media.xml b/app/src/main/res/layout/conversation_settings_shared_media.xml deleted file mode 100644 index 258fade0a6..0000000000 --- a/app/src/main/res/layout/conversation_settings_shared_media.xml +++ /dev/null @@ -1,7 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_settings_toolbar.xml b/app/src/main/res/layout/conversation_settings_toolbar.xml deleted file mode 100644 index bcfbaf105e..0000000000 --- a/app/src/main/res/layout/conversation_settings_toolbar.xml +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/conversation_settings.xml b/app/src/main/res/menu/conversation_settings.xml deleted file mode 100644 index 49f3923357..0000000000 --- a/app/src/main/res/menu/conversation_settings.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/conversation_settings.xml b/app/src/main/res/navigation/conversation_settings.xml index e82c281f96..303bbd6720 100644 --- a/app/src/main/res/navigation/conversation_settings.xml +++ b/app/src/main/res/navigation/conversation_settings.xml @@ -52,6 +52,10 @@ app:argType="long[]" app:nullable="true" /> + + End group This group has ended. + + %1$s ยท %2$s Archive chat diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/CallRowResourcesTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/CallRowResourcesTest.kt new file mode 100644 index 0000000000..7f198d3808 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/CallRowResourcesTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.CallTable +import org.thoughtcrime.securesms.recipients.RecipientId + +class CallRowResourcesTest { + + @Test + fun `incoming audio call`() { + val call = call(CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.ACCEPTED) + + assertEquals(R.drawable.symbol_arrow_downleft_24, CallRowResources.iconRes(call)) + assertEquals(R.string.MessageRecord_incoming_voice_call, CallRowResources.typeStringRes(call)) + } + + @Test + fun `incoming video call`() { + val call = call(CallTable.Type.VIDEO_CALL, CallTable.Direction.INCOMING, CallTable.Event.ACCEPTED) + + assertEquals(R.drawable.symbol_arrow_downleft_24, CallRowResources.iconRes(call)) + assertEquals(R.string.MessageRecord_incoming_video_call, CallRowResources.typeStringRes(call)) + } + + @Test + fun `outgoing audio call`() { + val call = call(CallTable.Type.AUDIO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.ACCEPTED) + + assertEquals(R.drawable.symbol_arrow_upright_24, CallRowResources.iconRes(call)) + assertEquals(R.string.MessageRecord_outgoing_voice_call, CallRowResources.typeStringRes(call)) + } + + @Test + fun `outgoing video call`() { + val call = call(CallTable.Type.VIDEO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.ACCEPTED) + + assertEquals(R.drawable.symbol_arrow_upright_24, CallRowResources.iconRes(call)) + assertEquals(R.string.MessageRecord_outgoing_video_call, CallRowResources.typeStringRes(call)) + } + + @Test + fun `unanswered outgoing calls read as declined`() { + val audio = call(CallTable.Type.AUDIO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.NOT_ACCEPTED) + val video = call(CallTable.Type.VIDEO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.NOT_ACCEPTED) + + assertEquals(R.string.MessageRecord_unanswered_voice_call, CallRowResources.typeStringRes(audio)) + assertEquals(R.string.MessageRecord_unanswered_video_call, CallRowResources.typeStringRes(video)) + } + + @Test + fun `missed calls`() { + val audio = call(CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED) + val video = call(CallTable.Type.VIDEO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED) + + assertEquals(R.drawable.symbol_missed_incoming_24, CallRowResources.iconRes(audio)) + assertEquals(R.string.MessageRecord_missed_voice_call, CallRowResources.typeStringRes(audio)) + assertEquals(R.string.MessageRecord_missed_video_call, CallRowResources.typeStringRes(video)) + } + + @Test + fun `missed calls declined by a notification profile get their own label`() { + val audio = call(CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED_NOTIFICATION_PROFILE) + val video = call(CallTable.Type.VIDEO_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED_NOTIFICATION_PROFILE) + + assertEquals(R.string.MessageRecord_missed_voice_call_notification_profile, CallRowResources.typeStringRes(audio)) + assertEquals(R.string.MessageRecord_missed_video_call_notification_profile, CallRowResources.typeStringRes(video)) + } + + @Test + fun `incoming calls we declined read as missed`() { + val audio = call(CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.DECLINED) + val video = call(CallTable.Type.VIDEO_CALL, CallTable.Direction.INCOMING, CallTable.Event.DECLINED) + + assertEquals(R.drawable.symbol_missed_incoming_24, CallRowResources.iconRes(audio)) + assertEquals(R.string.MessageRecord_missed_voice_call, CallRowResources.typeStringRes(audio)) + assertEquals(R.string.MessageRecord_missed_video_call, CallRowResources.typeStringRes(video)) + } + + @Test + fun `incoming calls the caller gave up on read as declined`() { + val audio = call(CallTable.Type.AUDIO_CALL, CallTable.Direction.INCOMING, CallTable.Event.NOT_ACCEPTED) + val video = call(CallTable.Type.VIDEO_CALL, CallTable.Direction.INCOMING, CallTable.Event.NOT_ACCEPTED) + + assertEquals(R.drawable.symbol_missed_incoming_24, CallRowResources.iconRes(audio)) + assertEquals(R.string.MessageRecord_declined_voice_call, CallRowResources.typeStringRes(audio)) + assertEquals(R.string.MessageRecord_declined_video_call, CallRowResources.typeStringRes(video)) + } + + @Test + fun `group call that we joined`() { + val call = call(CallTable.Type.GROUP_CALL, CallTable.Direction.INCOMING, CallTable.Event.JOINED) + + assertEquals(R.drawable.symbol_group_24, CallRowResources.iconRes(call)) + assertEquals(R.string.CallPreference__group_call, CallRowResources.typeStringRes(call)) + } + + @Test + fun `generic group call we never joined reads as missed`() { + val call = call(CallTable.Type.GROUP_CALL, CallTable.Direction.INCOMING, CallTable.Event.GENERIC_GROUP_CALL) + + assertEquals(R.drawable.symbol_missed_incoming_24, CallRowResources.iconRes(call)) + assertEquals(R.string.CallPreference__missed_group_call, CallRowResources.typeStringRes(call)) + } + + @Test + fun `generic group call we joined reads as a group call`() { + val call = call(CallTable.Type.GROUP_CALL, CallTable.Direction.INCOMING, CallTable.Event.GENERIC_GROUP_CALL, didLocalUserJoin = true) + + assertEquals(R.drawable.symbol_group_24, CallRowResources.iconRes(call)) + assertEquals(R.string.CallPreference__group_call, CallRowResources.typeStringRes(call)) + } + + @Test + fun `missed group call declined by a notification profile gets its own label`() { + val call = call(CallTable.Type.GROUP_CALL, CallTable.Direction.INCOMING, CallTable.Event.MISSED_NOTIFICATION_PROFILE) + + assertEquals(R.drawable.symbol_missed_incoming_24, CallRowResources.iconRes(call)) + assertEquals(R.string.CallPreference__missed_group_call_notification_profile, CallRowResources.typeStringRes(call)) + } + + @Test + fun `incoming group call ring we accepted`() { + val call = call(CallTable.Type.GROUP_CALL, CallTable.Direction.INCOMING, CallTable.Event.ACCEPTED) + + assertEquals(R.drawable.symbol_arrow_downleft_24, CallRowResources.iconRes(call)) + assertEquals(R.string.CallPreference__incoming_group_call, CallRowResources.typeStringRes(call)) + } + + @Test + fun `outgoing group call`() { + val call = call(CallTable.Type.GROUP_CALL, CallTable.Direction.OUTGOING, CallTable.Event.ACCEPTED) + + assertEquals(R.drawable.symbol_arrow_upright_24, CallRowResources.iconRes(call)) + assertEquals(R.string.CallPreference__outgoing_group_call, CallRowResources.typeStringRes(call)) + } + + private fun call( + type: CallTable.Type, + direction: CallTable.Direction, + event: CallTable.Event, + didLocalUserJoin: Boolean = false + ): CallTable.Call { + return CallTable.Call( + callId = 1L, + peer = RecipientId.from(1L), + type = type, + direction = direction, + event = event, + messageId = 1L, + timestamp = 1000L, + ringerRecipient = null, + isGroupCallActive = false, + didLocalUserJoin = didLocalUserJoin, + read = true + ) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/GroupSettingsStateTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/GroupSettingsStateTest.kt deleted file mode 100644 index e10733b67b..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/GroupSettingsStateTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2025 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.components.settings.conversation - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import org.thoughtcrime.securesms.groups.GroupId - -class GroupSettingsStateTest { - - private val v2GroupId = GroupId.v2(org.signal.libsignal.zkgroup.groups.GroupMasterKey(ByteArray(32))) - private val v1GroupId = GroupId.v1(ByteArray(16)) - - private fun createState( - groupId: GroupId = v2GroupId, - isActive: Boolean = true, - isSelfAdmin: Boolean = true, - canLeave: Boolean = true - ): SpecificSettingsState.GroupSettingsState { - return SpecificSettingsState.GroupSettingsState( - groupId = groupId, - isActive = isActive, - isSelfAdmin = isSelfAdmin, - canLeave = canLeave - ) - } - - @Test - fun `canEndGroup is true when active v2 group and self is admin`() { - assertTrue(createState().canEndGroup) - } - - @Test - fun `canEndGroup is false when group is not active`() { - assertFalse(createState(isActive = false).canEndGroup) - } - - @Test - fun `canEndGroup is false when self is not admin`() { - assertFalse(createState(isSelfAdmin = false).canEndGroup) - } - - @Test - fun `canEndGroup is false for v1 group`() { - assertFalse(createState(groupId = v1GroupId).canEndGroup) - } -} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsStateTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsStateTest.kt new file mode 100644 index 0000000000..4c5cc247d6 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsStateTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.group + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.signal.libsignal.zkgroup.groups.GroupMasterKey +import org.thoughtcrime.securesms.groups.GroupId + +class GroupSettingsStateTest { + + private val v2GroupId = GroupId.v2(GroupMasterKey(ByteArray(GroupMasterKey.SIZE))) + private val v1GroupId = GroupId.v1(ByteArray(16)) + private val mmsGroupId = GroupId.mms(ByteArray(16)) + + private fun createState( + groupId: GroupId = v2GroupId, + isActive: Boolean = true, + isSelfAdmin: Boolean = true, + isAnnouncementGroup: Boolean = false + ): GroupSettingsState { + return GroupSettingsState( + groupId = groupId, + isActive = isActive, + isSelfAdmin = isSelfAdmin, + isAnnouncementGroup = isAnnouncementGroup + ) + } + + @Test + fun `canEndGroup is true when active v2 group and self is admin`() { + assertTrue(createState().canEndGroup) + } + + @Test + fun `canEndGroup is false when group is not active`() { + assertFalse(createState(isActive = false).canEndGroup) + } + + @Test + fun `canEndGroup is false when self is not admin`() { + assertFalse(createState(isSelfAdmin = false).canEndGroup) + } + + @Test + fun `canEndGroup is false for v1 group`() { + assertFalse(createState(groupId = v1GroupId).canEndGroup) + } + + @Test + fun `canLeave is true for an active push group`() { + assertTrue(createState().canLeave) + } + + @Test + fun `canLeave is true for an active v1 group`() { + assertTrue(createState(groupId = v1GroupId).canLeave) + } + + @Test + fun `canLeave is false for an mms group`() { + assertFalse(createState(groupId = mmsGroupId).canLeave) + } + + @Test + fun `canLeave is false for an inactive group`() { + assertFalse(createState(isActive = false).canLeave) + } + + @Test + fun `isAnnouncementGroupRestricted is true for non-admins of an announcement group`() { + assertTrue(createState(isAnnouncementGroup = true, isSelfAdmin = false).isAnnouncementGroupRestricted) + } + + @Test + fun `isAnnouncementGroupRestricted is false for admins of an announcement group`() { + assertFalse(createState(isAnnouncementGroup = true, isSelfAdmin = true).isAnnouncementGroupRestricted) + } + + @Test + fun `isAnnouncementGroupRestricted is false for a normal group`() { + assertFalse(createState(isAnnouncementGroup = false, isSelfAdmin = false).isAnnouncementGroupRestricted) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsViewModelTest.kt new file mode 100644 index 0000000000..f22fdb4392 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/group/GroupSettingsViewModelTest.kt @@ -0,0 +1,711 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.group + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.signal.libsignal.zkgroup.groups.GroupMasterKey +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsAction +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository.GroupDetails +import org.thoughtcrime.securesms.components.settings.conversation.GroupCapacityResult +import org.thoughtcrime.securesms.components.settings.conversation.group.GroupSettingsState.Dialog +import org.thoughtcrime.securesms.components.settings.conversation.shared.GroupMember +import org.thoughtcrime.securesms.components.settings.conversation.shared.LegacyGroupState +import org.thoughtcrime.securesms.database.MediaTable +import org.thoughtcrime.securesms.database.model.StoryViewState +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.SelectionLimits +import org.thoughtcrime.securesms.groups.memberlabel.MemberLabel +import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import org.thoughtcrime.securesms.groups.ui.GroupChangeResult +import org.thoughtcrime.securesms.groups.v2.GroupAddMembersResult +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule + +@OptIn(ExperimentalCoroutinesApi::class) +class GroupSettingsViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val dispatcherRule = CoroutineDispatcherRule(testDispatcher) + + private val repository = mockk(relaxUnitFun = true) + private val detailsFlow = MutableStateFlow(groupDetails()) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + every { repository.isDeprecatedOrUnregistered() } returns false + every { repository.isStarredMessagesEnabled() } returns false + every { repository.isInternalUser() } returns false + every { repository.isInternalRecipientDetailsEnabled() } returns false + every { repository.isStoriesFeatureEnabled() } returns false + every { repository.isAddToStoryAvailable() } returns true + every { repository.isBlockable(any()) } returns true + every { repository.observeGroupDetails(GROUP_ID) } returns detailsFlow + every { repository.observeStoryViewState(GROUP_ID) } returns flowOf(StoryViewState.NONE) + every { repository.observeCalls(any>(), any()) } returns flowOf(emptyList()) + coEvery { repository.getThreadId(GROUP_ID) } returns THREAD_ID + coEvery { repository.isArchived(any()) } returns false + coEvery { repository.getSharedMedia(any(), any()) } returns emptyList() + coEvery { repository.getMemberLabels(any(), any()) } returns emptyMap() + coEvery { repository.canSetOwnMemberLabel(any()) } returns false + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel(callMessageIds: LongArray = longArrayOf()): GroupSettingsViewModel { + return GroupSettingsViewModel( + groupId = GROUP_ID, + callMessageIds = callMessageIds, + repository = repository + ) + } + + private fun TestScope.collectActions(viewModel: GroupSettingsViewModel): List { + val actions = mutableListOf() + backgroundScope.launch { viewModel.actions.collect { actions += it } } + return actions + } + + @Test + fun `group details populate the state`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + val state = viewModel.state.value + assertEquals(GROUP_ID, state.groupId) + assertEquals("Deep Space Nine", state.title) + assertEquals("Bajoran space station", state.description) + assertEquals("2 members", state.membershipCountDescription) + assertTrue(state.isSelfAdmin) + assertTrue(state.isActive) + assertTrue(state.detailsLoaded) + assertTrue(state.isLoaded) + } + + @Test + fun `group call bar offers video, mute, search, and stories`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + val callBar = viewModel.state.value.callBar + assertTrue(callBar.isVideoAvailable) + assertTrue(callBar.isMuteAvailable) + assertTrue(callBar.isSearchAvailable) + assertTrue(callBar.isAddToStoryAvailable) + assertFalse(callBar.isAudioAvailable) + } + + @Test + fun `stories are unavailable when the feature is turned off`() = runTest(testDispatcher) { + every { repository.isAddToStoryAvailable() } returns false + + val viewModel = createViewModel() + + assertFalse(viewModel.state.value.callBar.isAddToStoryAvailable) + } + + @Test + fun `message button replaces search when opened for a call`() = runTest(testDispatcher) { + val viewModel = createViewModel(callMessageIds = longArrayOf(1L, 2L)) + + assertTrue(viewModel.state.value.callBar.isMessageAvailable) + assertFalse(viewModel.state.value.callBar.isSearchAvailable) + } + + @Test + fun `members are shown in full when there are six or fewer`() = runTest(testDispatcher) { + detailsFlow.value = groupDetails(members = members(6)) + + val viewModel = createViewModel() + + assertEquals(6, viewModel.state.value.members.size) + assertFalse(viewModel.state.value.canShowMoreMembers) + } + + @Test + fun `members collapse to five when there are more than six`() = runTest(testDispatcher) { + detailsFlow.value = groupDetails(members = members(7)) + + val viewModel = createViewModel() + + val state = viewModel.state.value + assertEquals(5, state.members.size) + assertEquals(7, state.allMembers.size) + assertTrue(state.canShowMoreMembers) + } + + @Test + fun `revealing all members expands the list`() = runTest(testDispatcher) { + detailsFlow.value = groupDetails(members = members(7)) + val viewModel = createViewModel() + + viewModel.onEvent(GroupSettingsEvent.RevealAllMembersClicked) + + val state = viewModel.state.value + assertEquals(7, state.members.size) + assertTrue(state.membersExpanded) + assertFalse(state.canShowMoreMembers) + } + + @Test + fun `an expanded member list stays expanded across group updates`() = runTest(testDispatcher) { + detailsFlow.value = groupDetails(members = members(7)) + val viewModel = createViewModel() + viewModel.onEvent(GroupSettingsEvent.RevealAllMembersClicked) + + detailsFlow.value = groupDetails(members = members(7), title = "Terok Nor") + + assertEquals(7, viewModel.state.value.members.size) + assertFalse(viewModel.state.value.canShowMoreMembers) + } + + @Test + fun `member labels load whenever the membership changes`() = runTest(testDispatcher) { + val label = StyledMemberLabel(MemberLabel(emoji = null, text = "Captain"), tintColor = 1) + coEvery { repository.getMemberLabels(any(), any()) } returns mapOf(MEMBER_ID to label) + coEvery { repository.canSetOwnMemberLabel(any()) } returns true + + val viewModel = createViewModel() + + assertEquals(label, viewModel.state.value.memberLabels[MEMBER_ID]) + assertTrue(viewModel.state.value.canSetOwnMemberLabel) + } + + @Test + fun `edit group click opens the group profile editor`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.EditGroupClicked) + + assertEquals(ConversationSettingsAction.EditGroupProfile(GROUP_ID), actions.single()) + } + + @Test + fun `group description rows open the editor and the viewer`() = runTest(testDispatcher) { + detailsFlow.value = groupDetails(descriptionShouldLinkify = true) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.EditGroupDescriptionClicked) + viewModel.onEvent(GroupSettingsEvent.ViewGroupDescriptionClicked) + + assertEquals( + listOf( + ConversationSettingsAction.EditGroupDescription(GROUP_ID), + ConversationSettingsAction.ShowGroupDescriptionDialog(GROUP_ID, shouldLinkify = true) + ), + actions + ) + } + + @Test + fun `legacy group rows open the explainer and the invite flow`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.LegacyGroupLearnMoreClicked) + viewModel.onEvent(GroupSettingsEvent.LegacyGroupMmsWarningClicked) + + assertEquals( + listOf(ConversationSettingsAction.ShowGroupsLearnMore, ConversationSettingsAction.ShowInviteFriends), + actions + ) + } + + @Test + fun `member search click allows adding when the group is active and we have permission`() = runTest(testDispatcher) { + detailsFlow.value = groupDetails(canAddMembers = true, groupLinkEnabled = true) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.MemberSearchClicked) + + assertEquals(ConversationSettingsAction.NavigateToMemberSearch(GROUP_ID, canAdd = true, hasGroupLink = true), actions.single()) + } + + @Test + fun `member search click disallows adding to a terminated group`() = runTest(testDispatcher) { + detailsFlow.value = groupDetails(canAddMembers = true, isTerminated = true) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.MemberSearchClicked) + + assertEquals(ConversationSettingsAction.NavigateToMemberSearch(GROUP_ID, canAdd = false, hasGroupLink = false), actions.single()) + } + + @Test + fun `add members click opens the picker when the group has room`() = runTest(testDispatcher) { + coEvery { repository.getGroupCapacity(GROUP_ID) } returns capacity(remaining = 10) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.AddMembersClicked) + + val action = actions.single() as ConversationSettingsAction.AddMembersToGroup + assertEquals(GROUP_ID, action.groupId) + } + + @Test + fun `add members click warns when the group is full`() = runTest(testDispatcher) { + coEvery { repository.getGroupCapacity(GROUP_ID) } returns capacity(remaining = 0) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.AddMembersClicked) + + assertEquals(ConversationSettingsAction.ShowGroupHardLimitDialog, actions.single()) + } + + @Test + fun `adding members reports how many were added`() = runTest(testDispatcher) { + val selected = listOf(RecipientId.from(77L)) + coEvery { repository.addMembers(GROUP_ID, selected) } returns GroupAddMembersResult.Success(2, emptyList()) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.AddMembersSelected(selected)) + + assertEquals(ConversationSettingsAction.ShowMembersAdded(2), actions.single()) + assertEquals(Dialog.None, viewModel.state.value.dialog) + } + + @Test + fun `adding members reports who was invited`() = runTest(testDispatcher) { + val selected = listOf(RecipientId.from(77L)) + val invited = listOf(mockk(relaxed = true)) + coEvery { repository.addMembers(GROUP_ID, selected) } returns GroupAddMembersResult.Success(0, invited) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.AddMembersSelected(selected)) + + assertEquals(ConversationSettingsAction.ShowGroupInvitesSentDialog(invited), actions.single()) + } + + @Test + fun `adding members surfaces the failure reason`() = runTest(testDispatcher) { + val selected = listOf(RecipientId.from(77L)) + coEvery { repository.addMembers(GROUP_ID, selected) } returns GroupAddMembersResult.Failure(GroupChangeFailureReason.NETWORK) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.AddMembersSelected(selected)) + + assertEquals(ConversationSettingsAction.ShowAddMembersError(GroupChangeFailureReason.NETWORK), actions.single()) + } + + @Test + fun `member click opens the recipient sheet`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.MemberClicked(MEMBER_ID)) + + assertEquals(ConversationSettingsAction.ShowRecipientBottomSheet(MEMBER_ID, GROUP_ID), actions.single()) + } + + @Test + fun `clicking yourself opens the member label editor when you have no label yet`() = runTest(testDispatcher) { + coEvery { repository.canSetOwnMemberLabel(any()) } returns true + detailsFlow.value = groupDetails(members = listOf(GroupMember(recipient(SELF_MEMBER_ID, isSelf = true), isAdmin = false))) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.MemberClicked(SELF_MEMBER_ID)) + + assertEquals(ConversationSettingsAction.NavigateToMemberLabel(GROUP_ID), actions.single()) + } + + @Test + fun `clicking yourself opens the recipient sheet once you already have a label`() = runTest(testDispatcher) { + coEvery { repository.canSetOwnMemberLabel(any()) } returns true + coEvery { repository.getMemberLabels(any(), any()) } returns mapOf(SELF_MEMBER_ID to StyledMemberLabel(MemberLabel(null, "Captain"), 1)) + detailsFlow.value = groupDetails(members = listOf(GroupMember(recipient(SELF_MEMBER_ID, isSelf = true), isAdmin = false))) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.MemberClicked(SELF_MEMBER_ID)) + + assertEquals(ConversationSettingsAction.ShowRecipientBottomSheet(SELF_MEMBER_ID, GROUP_ID), actions.single()) + } + + @Test + fun `clicking yourself opens the recipient sheet when you cannot set a label`() = runTest(testDispatcher) { + coEvery { repository.canSetOwnMemberLabel(any()) } returns false + detailsFlow.value = groupDetails(members = listOf(GroupMember(recipient(SELF_MEMBER_ID, isSelf = true), isAdmin = false))) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.MemberClicked(SELF_MEMBER_ID)) + + assertEquals(ConversationSettingsAction.ShowRecipientBottomSheet(SELF_MEMBER_ID, GROUP_ID), actions.single()) + } + + @Test + fun `member avatar click always opens the recipient sheet`() = runTest(testDispatcher) { + coEvery { repository.canSetOwnMemberLabel(any()) } returns true + detailsFlow.value = groupDetails(members = listOf(GroupMember(recipient(SELF_MEMBER_ID, isSelf = true), isAdmin = false))) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.MemberAvatarClicked(SELF_MEMBER_ID)) + + assertEquals(ConversationSettingsAction.ShowRecipientBottomSheet(SELF_MEMBER_ID, GROUP_ID), actions.single()) + } + + @Test + fun `group management rows navigate to their destinations`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.GroupLinkClicked) + viewModel.onEvent(GroupSettingsEvent.GroupMemberLabelClicked) + viewModel.onEvent(GroupSettingsEvent.RequestsAndInvitesClicked) + viewModel.onEvent(GroupSettingsEvent.PermissionsClicked) + viewModel.onEvent(GroupSettingsEvent.LeaveGroupClicked) + + assertEquals( + listOf( + ConversationSettingsAction.NavigateToShareableGroupLink(GROUP_ID), + ConversationSettingsAction.NavigateToMemberLabel(GROUP_ID), + ConversationSettingsAction.OpenRequestsAndInvites(GROUP_ID.requireV2()), + ConversationSettingsAction.NavigateToPermissions(GROUP_ID), + ConversationSettingsAction.ShowLeaveGroupDialog(GROUP_ID) + ), + actions + ) + } + + @Test + fun `tapping the disabled member label row explains why it is disabled`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.GroupMemberLabelDisabledClicked) + + assertEquals(ConversationSettingsAction.ShowMemberLabelPermissionError, actions.single()) + } + + @Test + fun `end group click carries the group title`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.EndGroupClicked) + + assertEquals(ConversationSettingsAction.ShowEndGroupDialog(GROUP_ID.requireV2(), "Deep Space Nine"), actions.single()) + } + + @Test + fun `video call click warns non-admins of an announcement group instead of starting a call`() = runTest(testDispatcher) { + detailsFlow.value = groupDetails(isAnnouncementGroup = true, isSelfAdmin = false) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.VideoCallClicked) + + assertEquals(emptyList(), actions) + assertEquals(Dialog.CannotStartGroupCall, viewModel.state.value.dialog) + } + + @Test + fun `video call click starts the call for an admin of an announcement group`() = runTest(testDispatcher) { + detailsFlow.value = groupDetails(isAnnouncementGroup = true, isSelfAdmin = true) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.VideoCallClicked) + + assertEquals(ConversationSettingsAction.StartVideoCall::class, actions.single()::class) + assertEquals(Dialog.None, viewModel.state.value.dialog) + } + + @Test + fun `add to story click warns non-admins of an announcement group`() = runTest(testDispatcher) { + detailsFlow.value = groupDetails(isAnnouncementGroup = true, isSelfAdmin = false) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.AddToStoryClicked) + + assertEquals(emptyList(), actions) + assertEquals(Dialog.CannotAddToGroupStory, viewModel.state.value.dialog) + } + + @Test + fun `add to story click adds to the story for an admin`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.AddToStoryClicked) + + assertEquals(ConversationSettingsAction.AddToGroupStory(GROUP_RECIPIENT_ID), actions.single()) + } + + @Test + fun `mute click shows the mute menu, then selecting a duration mutes the group`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + viewModel.onEvent(GroupSettingsEvent.MuteClicked) + assertEquals(Dialog.MuteMenu, viewModel.state.value.dialog) + + viewModel.onEvent(GroupSettingsEvent.MuteDurationSelected(4321L)) + + coVerify(exactly = 1) { repository.setMuteUntil(GROUP_ID, 4321L) } + assertEquals(Dialog.None, viewModel.state.value.dialog) + } + + @Test + fun `mute click shows the unmute dialog when the group is already muted`() = runTest(testDispatcher) { + detailsFlow.value = groupDetails(isMuted = true) + val viewModel = createViewModel() + + viewModel.onEvent(GroupSettingsEvent.MuteClicked) + + assertEquals(Dialog.Unmute, viewModel.state.value.dialog) + } + + @Test + fun `unmuting clears the group's mute until timestamp`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + viewModel.onEvent(GroupSettingsEvent.UnmuteConfirmed) + + coVerify(exactly = 1) { repository.setMuteUntil(GROUP_ID, 0L) } + } + + @Test + fun `custom mute time click closes the menu and asks the host for the time picker`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + viewModel.onEvent(GroupSettingsEvent.MuteClicked) + + viewModel.onEvent(GroupSettingsEvent.MuteUntilCustomTimeClicked) + + assertEquals(Dialog.None, viewModel.state.value.dialog) + assertEquals(ConversationSettingsAction.ShowMuteUntilTimePicker, actions.single()) + } + + @Test + fun `blocking the group succeeds quietly`() = runTest(testDispatcher) { + coEvery { repository.block(GROUP_ID) } returns GroupChangeResult.SUCCESS + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.BlockConfirmed) + + assertEquals(emptyList(), actions) + } + + @Test + fun `blocking the group surfaces the failure reason`() = runTest(testDispatcher) { + coEvery { repository.block(GROUP_ID) } returns GroupChangeResult.failure(GroupChangeFailureReason.NO_RIGHTS) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.BlockConfirmed) + + assertEquals(ConversationSettingsAction.ShowBlockError(GroupChangeFailureReason.NO_RIGHTS), actions.single()) + } + + @Test + fun `unblocking the group calls through to the repository`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + viewModel.onEvent(GroupSettingsEvent.UnblockConfirmed) + + coVerify(exactly = 1) { repository.unblock(GROUP_ID) } + } + + @Test + fun `archived state comes from the thread`() = runTest(testDispatcher) { + coEvery { repository.isArchived(any()) } returns true + + val viewModel = createViewModel() + + assertTrue(viewModel.state.value.isArchived) + } + + @Test + fun `archive chat click archives and leaves the screen`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.ArchiveChatClicked) + + coVerify(exactly = 1) { repository.setArchived(THREAD_ID, true) } + assertTrue(viewModel.state.value.isArchived) + assertEquals(ConversationSettingsAction.GoToConversationList, actions.single()) + } + + @Test + fun `archive chat click unarchives without leaving the screen`() = runTest(testDispatcher) { + coEvery { repository.isArchived(any()) } returns true + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.ArchiveChatClicked) + + coVerify(exactly = 1) { repository.setArchived(THREAD_ID, false) } + assertFalse(viewModel.state.value.isArchived) + assertEquals(emptyList(), actions) + } + + @Test + fun `delete chat click deletes, closes the progress dialog, and leaves the screen`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.DeleteChatClicked) + + coVerify(exactly = 1) { repository.deleteChat(THREAD_ID) } + assertEquals(Dialog.None, viewModel.state.value.dialog) + assertEquals(ConversationSettingsAction.GoToConversationList, actions.single()) + } + + @Test + fun `see all shared media click opens the media overview for the thread`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.SeeAllSharedMediaClicked) + + assertEquals(ConversationSettingsAction.ShowMediaOverview(THREAD_ID), actions.single()) + } + + @Test + fun `shared media click reports the media is not sent yet when there is no attachment`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(GroupSettingsEvent.SharedMediaClicked(mediaRecord(), isLtr = true)) + + assertEquals(ConversationSettingsAction.ShowMediaNotSentYet, actions.single()) + } + + private companion object { + val GROUP_ID: GroupId.V2 = GroupId.v2(GroupMasterKey(ByteArray(GroupMasterKey.SIZE) { 1 })) + val GROUP_RECIPIENT_ID: RecipientId = RecipientId.from(1L) + val MEMBER_ID: RecipientId = RecipientId.from(10L) + val SELF_MEMBER_ID: RecipientId = RecipientId.from(11L) + const val THREAD_ID = 5L + + fun mediaRecord(): MediaTable.MediaRecord { + return MediaTable.MediaRecord( + attachment = null, + recipientId = MEMBER_ID, + threadRecipientId = GROUP_RECIPIENT_ID, + threadId = THREAD_ID, + messageId = 4L, + date = 1000L, + isOutgoing = false + ) + } + + fun groupDetails( + title: String = "Deep Space Nine", + description: String? = "Bajoran space station", + descriptionShouldLinkify: Boolean = false, + members: List = members(2), + isSelfAdmin: Boolean = true, + canEditGroupAttributes: Boolean = true, + canAddMembers: Boolean = true, + isActive: Boolean = true, + isTerminated: Boolean = false, + isAnnouncementGroup: Boolean = false, + groupLinkEnabled: Boolean = false, + isMuted: Boolean = false + ): GroupDetails { + return GroupDetails( + recipient = groupRecipient(isMuted), + title = title, + description = description, + descriptionShouldLinkify = descriptionShouldLinkify, + members = members, + isSelfAdmin = isSelfAdmin, + canEditGroupAttributes = canEditGroupAttributes, + canAddMembers = canAddMembers, + isActive = isActive, + isTerminated = isTerminated, + isAnnouncementGroup = isAnnouncementGroup, + groupLinkEnabled = groupLinkEnabled, + membershipCountDescription = "${members.size} members", + legacyGroupState = LegacyGroupState.NONE + ) + } + + fun groupRecipient(isMuted: Boolean = false): Recipient { + return mockk(relaxed = true) { + every { id } returns GROUP_RECIPIENT_ID + every { isPushV2Group } returns true + every { isPushGroup } returns true + every { isGroup } returns true + every { isIndividual } returns false + every { isSelf } returns false + every { isBlocked } returns false + every { isActiveGroup } returns true + every { isReleaseNotes } returns false + every { this@mockk.isMuted } returns isMuted + every { expiresInSeconds } returns 0 + } + } + + fun members(count: Int): List { + return (0 until count).map { index -> + GroupMember(recipient(RecipientId.from(10L + index), isSelf = false), isAdmin = index == 0) + } + } + + fun recipient(recipientId: RecipientId, isSelf: Boolean): Recipient { + return mockk(relaxed = true) { + every { id } returns recipientId + every { this@mockk.isSelf } returns isSelf + } + } + + /** A real capacity result, so that the remaining/limit numbers stay consistent with each other. */ + fun capacity(remaining: Int, memberCount: Int = 2): GroupCapacityResult { + val members = (0 until memberCount).map { RecipientId.from(100L + it) } + + return GroupCapacityResult( + SELF_MEMBER_ID, + members, + SelectionLimits(members.size + remaining, members.size + remaining), + false + ) + } + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsViewModelTest.kt new file mode 100644 index 0000000000..bb7cf6c714 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/conversation/individual/IndividualSettingsViewModelTest.kt @@ -0,0 +1,841 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.conversation.individual + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.signal.core.models.database.AttachmentId +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.attachments.Cdn +import org.thoughtcrime.securesms.attachments.DatabaseAttachment +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsAction +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsKind +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsRepository +import org.thoughtcrime.securesms.components.settings.conversation.individual.IndividualSettingsState.Dialog +import org.thoughtcrime.securesms.database.AttachmentTable +import org.thoughtcrime.securesms.database.MediaTable +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.model.IdentityRecord +import org.thoughtcrime.securesms.database.model.StoryViewState +import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason +import org.thoughtcrime.securesms.groups.ui.GroupChangeResult +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule +import org.signal.core.util.Result as CoreResult + +@OptIn(ExperimentalCoroutinesApi::class) +class IndividualSettingsViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @get:Rule + val dispatcherRule = CoroutineDispatcherRule(testDispatcher) + + private val repository = mockk(relaxUnitFun = true) + private val recipientFlow = MutableStateFlow(individual()) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + every { repository.isDeprecatedOrUnregistered() } returns false + every { repository.isStarredMessagesEnabled() } returns false + every { repository.isInternalUser() } returns false + every { repository.isInternalRecipientDetailsEnabled() } returns false + every { repository.isStoriesFeatureEnabled() } returns false + every { repository.isBlockable(any()) } returns true + every { repository.observeRecipient(RECIPIENT_ID) } returns recipientFlow + every { repository.observeStoryViewState(RECIPIENT_ID) } returns flowOf(StoryViewState.NONE) + every { repository.observeGroupsInCommon(RECIPIENT_ID) } returns flowOf(emptyList()) + every { repository.observeCalls(any>(), any()) } returns flowOf(emptyList()) + coEvery { repository.getThreadId(RECIPIENT_ID) } returns THREAD_ID + coEvery { repository.hasGroups() } returns true + coEvery { repository.getIdentity(RECIPIENT_ID) } returns null + coEvery { repository.getSharedMedia(any(), any()) } returns emptyList() + coEvery { repository.getGroupMembership(RECIPIENT_ID) } returns emptyList() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel( + callMessageIds: LongArray = longArrayOf(), + kind: ConversationSettingsKind = ConversationSettingsKind.INDIVIDUAL + ): IndividualSettingsViewModel { + return IndividualSettingsViewModel( + recipientId = RECIPIENT_ID, + kind = kind, + callMessageIds = callMessageIds, + repository = repository + ) + } + + private fun TestScope.collectActions(viewModel: IndividualSettingsViewModel): List { + val actions = mutableListOf() + backgroundScope.launch { viewModel.actions.collect { actions += it } } + return actions + } + + @Test + fun `recipient updates populate the call bar`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + val callBar = viewModel.state.value.callBar + assertTrue(callBar.isVideoAvailable) + assertTrue(callBar.isAudioAvailable) + assertTrue(callBar.isAudioSecure) + assertTrue(callBar.isMuteAvailable) + assertTrue(callBar.isSearchAvailable) + assertFalse(callBar.isMessageAvailable) + assertFalse(callBar.isAddToStoryAvailable) + } + + @Test + fun `message button replaces search when opened for a call`() = runTest(testDispatcher) { + val viewModel = createViewModel(callMessageIds = longArrayOf(1L, 2L)) + + assertTrue(viewModel.state.value.callBar.isMessageAvailable) + assertFalse(viewModel.state.value.callBar.isSearchAvailable) + } + + @Test + fun `blocked recipient cannot call or be called`() = runTest(testDispatcher) { + recipientFlow.value = individual(isBlocked = true) + + val viewModel = createViewModel() + + assertFalse(viewModel.state.value.callBar.isVideoAvailable) + assertFalse(viewModel.state.value.callBar.isAudioAvailable) + } + + @Test + fun `contact link state is add when the recipient has a visible phone number`() = runTest(testDispatcher) { + recipientFlow.value = individual(hasE164 = true, shouldShowE164 = true) + + val viewModel = createViewModel() + + assertEquals(ContactLinkState.ADD, viewModel.state.value.contactLinkState) + } + + @Test + fun `contact link state is open for a system contact`() = runTest(testDispatcher) { + recipientFlow.value = individual(isSystemContact = true) + + val viewModel = createViewModel() + + assertEquals(ContactLinkState.OPEN, viewModel.state.value.contactLinkState) + } + + @Test + fun `contact link state is none when the recipient is blocked`() = runTest(testDispatcher) { + recipientFlow.value = individual(isBlocked = true, isSystemContact = true) + + val viewModel = createViewModel() + + assertEquals(ContactLinkState.NONE, viewModel.state.value.contactLinkState) + } + + @Test + fun `disappearing messages lifespan comes from the recipient`() = runTest(testDispatcher) { + recipientFlow.value = individual(expiresInSeconds = 3600) + + val viewModel = createViewModel() + + assertEquals(3600, viewModel.state.value.disappearingMessagesLifespan) + } + + @Test + fun `thread id and shared media load on start`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + assertEquals(THREAD_ID, viewModel.state.value.threadId) + assertTrue(viewModel.state.value.sharedMediaLoaded) + assertTrue(viewModel.state.value.isLoaded) + } + + @Test + fun `identity record is loaded on start`() = runTest(testDispatcher) { + val identityRecord = mockk() + coEvery { repository.getIdentity(RECIPIENT_ID) } returns identityRecord + + val viewModel = createViewModel() + + assertEquals(identityRecord, viewModel.state.value.identityRecord) + } + + @Test + fun `groups in common are shown in full when there are six or fewer`() = runTest(testDispatcher) { + every { repository.observeGroupsInCommon(RECIPIENT_ID) } returns flowOf(groups(6)) + + val viewModel = createViewModel() + + assertEquals(6, viewModel.state.value.groupsInCommon.size) + assertFalse(viewModel.state.value.canShowMoreGroupsInCommon) + } + + @Test + fun `groups in common collapse to five when there are more than six`() = runTest(testDispatcher) { + every { repository.observeGroupsInCommon(RECIPIENT_ID) } returns flowOf(groups(7)) + + val viewModel = createViewModel() + + val state = viewModel.state.value + assertEquals(5, state.groupsInCommon.size) + assertEquals(7, state.allGroupsInCommon.size) + assertTrue(state.canShowMoreGroupsInCommon) + } + + @Test + fun `revealing all groups in common expands the list`() = runTest(testDispatcher) { + every { repository.observeGroupsInCommon(RECIPIENT_ID) } returns flowOf(groups(7)) + val viewModel = createViewModel() + + viewModel.onEvent(IndividualSettingsEvent.RevealAllGroupsInCommonClicked) + + val state = viewModel.state.value + assertEquals(7, state.groupsInCommon.size) + assertTrue(state.groupsInCommonExpanded) + assertFalse(state.canShowMoreGroupsInCommon) + } + + @Test + fun `headline click opens the about sheet`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.HeadlineClicked) + + assertEquals(ConversationSettingsAction.ShowAboutSheet::class, actions.single()::class) + } + + @Test + fun `avatar click shows the story dialog when stories are enabled and the recipient has a story`() = runTest(testDispatcher) { + every { repository.isStoriesFeatureEnabled() } returns true + every { repository.observeStoryViewState(RECIPIENT_ID) } returns flowOf(StoryViewState.UNVIEWED) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.AvatarClicked) + + assertEquals(ConversationSettingsAction.ShowStoryOrAvatarDialog::class, actions.single()::class) + } + + @Test + fun `avatar click shows the avatar preview when there is no story`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.AvatarClicked) + + assertEquals(ConversationSettingsAction.ShowAvatarPreview(RECIPIENT_ID), actions.single()) + } + + @Test + fun `badge click opens the badge sheet`() = runTest(testDispatcher) { + val badge = mockk() + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.BadgeClicked(badge)) + + assertEquals(ConversationSettingsAction.ShowBadgeSheet(RECIPIENT_ID, badge), actions.single()) + } + + @Test + fun `message and search clicks open the conversation`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.MessageClicked) + viewModel.onEvent(IndividualSettingsEvent.SearchClicked) + + assertEquals( + listOf( + ConversationSettingsAction.OpenConversation(RECIPIENT_ID, THREAD_ID, withSearchOpen = false), + ConversationSettingsAction.OpenConversation(RECIPIENT_ID, THREAD_ID, withSearchOpen = true) + ), + actions + ) + } + + @Test + fun `call buttons start the call`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.VideoCallClicked) + viewModel.onEvent(IndividualSettingsEvent.AudioCallClicked) + + assertEquals(ConversationSettingsAction.StartVideoCall::class, actions[0]::class) + assertEquals(ConversationSettingsAction.StartAudioCall::class, actions[1]::class) + } + + @Test + fun `disappearing messages click carries the current lifespan`() = runTest(testDispatcher) { + recipientFlow.value = individual(expiresInSeconds = 3600) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.DisappearingMessagesClicked) + + assertEquals(ConversationSettingsAction.NavigateToDisappearingMessages(RECIPIENT_ID, 3600), actions.single()) + } + + @Test + fun `nickname, wallpaper, and starred messages rows navigate`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.NicknameClicked) + viewModel.onEvent(IndividualSettingsEvent.ChatColorAndWallpaperClicked) + viewModel.onEvent(IndividualSettingsEvent.StarredMessagesClicked) + + assertEquals( + listOf( + ConversationSettingsAction.EditNickname(RECIPIENT_ID), + ConversationSettingsAction.OpenChatWallpaper(RECIPIENT_ID), + ConversationSettingsAction.OpenStarredMessages(THREAD_ID) + ), + actions + ) + } + + @Test + fun `sounds and notifications click uses the internal screen for internal users`() = runTest(testDispatcher) { + every { repository.isInternalUser() } returns true + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.SoundsAndNotificationsClicked) + + assertEquals(ConversationSettingsAction.NavigateToSoundsAndNotifications(RECIPIENT_ID, useInternalScreen = true), actions.single()) + } + + @Test + fun `contact rows carry the recipient`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.ContactDetailsClicked) + viewModel.onEvent(IndividualSettingsEvent.AddAsContactClicked) + + assertEquals(ConversationSettingsAction.ViewContact::class, actions[0]::class) + assertEquals(ConversationSettingsAction.AddContact::class, actions[1]::class) + } + + @Test + fun `view safety number click carries the identity record`() = runTest(testDispatcher) { + val identityRecord = mockk() + coEvery { repository.getIdentity(RECIPIENT_ID) } returns identityRecord + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.ViewSafetyNumberClicked) + + assertEquals(ConversationSettingsAction.ShowSafetyNumber(identityRecord), actions.single()) + } + + @Test + fun `internal details click navigates to internal details`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.InternalDetailsClicked) + + assertEquals(ConversationSettingsAction.NavigateToInternalDetails(RECIPIENT_ID), actions.single()) + } + + @Test + fun `see all shared media click opens the media overview for the thread`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.SeeAllSharedMediaClicked) + + assertEquals(ConversationSettingsAction.ShowMediaOverview(THREAD_ID), actions.single()) + } + + @Test + fun `shared media click shows the preview for a downloaded attachment`() = runTest(testDispatcher) { + val record = mediaRecord(attachment(transferState = AttachmentTable.TRANSFER_PROGRESS_DONE, hasUri = true)) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.SharedMediaClicked(record, isLtr = true)) + + assertEquals(ConversationSettingsAction.ShowMediaPreview(record, true), actions.single()) + } + + @Test + fun `shared media click downloads offloaded media that has no local file`() = runTest(testDispatcher) { + val record = mediaRecord(attachment(transferState = AttachmentTable.TRANSFER_RESTORE_OFFLOADED, hasUri = false)) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.SharedMediaClicked(record, isLtr = true)) + + assertEquals(ConversationSettingsAction.DownloadMedia(record), actions.single()) + } + + @Test + fun `shared media click reports the media is not sent yet when there is no attachment`() = runTest(testDispatcher) { + val record = mediaRecord(null) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.SharedMediaClicked(record, isLtr = true)) + + assertEquals(ConversationSettingsAction.ShowMediaNotSentYet, actions.single()) + } + + @Test + fun `shared media click reports the media is not sent yet when it is still in flight`() = runTest(testDispatcher) { + val record = mediaRecord(attachment(transferState = AttachmentTable.TRANSFER_PROGRESS_STARTED, hasUri = true)) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.SharedMediaClicked(record, isLtr = true)) + + assertEquals(ConversationSettingsAction.ShowMediaNotSentYet, actions.single()) + } + + @Test + fun `add to a group click looks up the current group membership`() = runTest(testDispatcher) { + val membership = listOf(RecipientId.from(50L)) + coEvery { repository.getGroupMembership(RECIPIENT_ID) } returns membership + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.AddToAGroupClicked) + + assertEquals(ConversationSettingsAction.AddToAGroup(RECIPIENT_ID, membership), actions.single()) + } + + @Test + fun `group in common click opens that group's conversation`() = runTest(testDispatcher) { + val group = groups(1).first() + every { repository.observeGroupsInCommon(RECIPIENT_ID) } returns flowOf(listOf(group)) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.GroupInCommonClicked(group.id)) + + assertEquals(ConversationSettingsAction.OpenGroupConversation(group), actions.single()) + } + + @Test + fun `group in common click for an unknown group does nothing`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.GroupInCommonClicked(RecipientId.from(999L))) + + assertEquals(emptyList(), actions) + } + + @Test + fun `mute click shows the mute menu, then selecting a duration mutes the recipient`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + viewModel.onEvent(IndividualSettingsEvent.MuteClicked) + assertEquals(Dialog.MuteMenu, viewModel.state.value.dialog) + + viewModel.onEvent(IndividualSettingsEvent.MuteDurationSelected(1234L)) + + coVerify(exactly = 1) { repository.setMuteUntil(RECIPIENT_ID, 1234L) } + assertEquals(Dialog.None, viewModel.state.value.dialog) + } + + @Test + fun `mute click shows the unmute dialog when the chat is already muted`() = runTest(testDispatcher) { + recipientFlow.value = individual(isMuted = true) + val viewModel = createViewModel() + + viewModel.onEvent(IndividualSettingsEvent.MuteClicked) + + assertEquals(Dialog.Unmute, viewModel.state.value.dialog) + } + + @Test + fun `unmuting clears the mute until timestamp`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + viewModel.onEvent(IndividualSettingsEvent.UnmuteConfirmed) + + coVerify(exactly = 1) { repository.setMuteUntil(RECIPIENT_ID, 0L) } + assertEquals(Dialog.None, viewModel.state.value.dialog) + } + + @Test + fun `custom mute time click closes the menu and asks the host for the time picker`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + viewModel.onEvent(IndividualSettingsEvent.MuteClicked) + + viewModel.onEvent(IndividualSettingsEvent.MuteUntilCustomTimeClicked) + + assertEquals(Dialog.None, viewModel.state.value.dialog) + assertEquals(ConversationSettingsAction.ShowMuteUntilTimePicker, actions.single()) + } + + @Test + fun `block click asks to block an unblocked recipient`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.BlockClicked) + + assertEquals(ConversationSettingsAction.ShowBlockDialog::class, actions.single()::class) + } + + @Test + fun `block click asks to unblock a blocked recipient`() = runTest(testDispatcher) { + recipientFlow.value = individual(isBlocked = true) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.BlockClicked) + + assertEquals(ConversationSettingsAction.ShowUnblockDialog::class, actions.single()::class) + } + + @Test + fun `blocking succeeds quietly`() = runTest(testDispatcher) { + coEvery { repository.block(RECIPIENT_ID) } returns GroupChangeResult.SUCCESS + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.BlockConfirmed) + + assertEquals(emptyList(), actions) + } + + @Test + fun `blocking surfaces the failure reason`() = runTest(testDispatcher) { + coEvery { repository.block(RECIPIENT_ID) } returns GroupChangeResult.failure(GroupChangeFailureReason.NETWORK) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.BlockConfirmed) + + assertEquals(ConversationSettingsAction.ShowBlockError(GroupChangeFailureReason.NETWORK), actions.single()) + } + + @Test + fun `unblocking calls through to the repository`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + viewModel.onEvent(IndividualSettingsEvent.UnblockConfirmed) + + coVerify(exactly = 1) { repository.unblock(RECIPIENT_ID) } + } + + @Test + fun `report spam click offers blocking when the recipient is not already blocked`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.ReportSpamClicked) + + assertEquals(true, (actions.single() as ConversationSettingsAction.ShowReportSpamDialog).canBlock) + } + + @Test + fun `report spam click does not offer blocking when the recipient is already blocked`() = runTest(testDispatcher) { + recipientFlow.value = individual(isBlocked = true) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.ReportSpamClicked) + + assertEquals(false, (actions.single() as ConversationSettingsAction.ShowReportSpamDialog).canBlock) + } + + @Test + fun `report spam confirmed reports and leaves the screen`() = runTest(testDispatcher) { + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.ReportSpamConfirmed) + + coVerify(exactly = 1) { repository.reportSpam(RECIPIENT_ID, THREAD_ID) } + assertEquals( + listOf(ConversationSettingsAction.ShowSpamReported, ConversationSettingsAction.GoToConversationList), + actions + ) + } + + @Test + fun `report spam confirmed does nothing without a thread`() = runTest(testDispatcher) { + coEvery { repository.getThreadId(RECIPIENT_ID) } returns -1L + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.ReportSpamConfirmed) + + coVerify(exactly = 0) { repository.reportSpam(any(), any()) } + assertEquals(emptyList(), actions) + } + + @Test + fun `block and report spam confirmed reports and leaves the screen on success`() = runTest(testDispatcher) { + coEvery { repository.blockAndReportSpam(RECIPIENT_ID, THREAD_ID) } returns CoreResult.success(Unit) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.BlockAndReportSpamConfirmed) + + assertEquals( + listOf(ConversationSettingsAction.ShowSpamReportedAndBlocked, ConversationSettingsAction.GoToConversationList), + actions + ) + } + + @Test + fun `block and report spam confirmed surfaces the failure reason`() = runTest(testDispatcher) { + coEvery { repository.blockAndReportSpam(RECIPIENT_ID, THREAD_ID) } returns CoreResult.failure(GroupChangeFailureReason.NETWORK) + val viewModel = createViewModel() + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.BlockAndReportSpamConfirmed) + + assertEquals(ConversationSettingsAction.ShowBlockError(GroupChangeFailureReason.NETWORK), actions.single()) + } + + @Test + fun `dialog dismissed clears the dialog`() = runTest(testDispatcher) { + val viewModel = createViewModel() + viewModel.onEvent(IndividualSettingsEvent.MuteClicked) + + viewModel.onEvent(IndividualSettingsEvent.DialogDismissed) + + assertEquals(Dialog.None, viewModel.state.value.dialog) + } + + @Test + fun `recipient refresh requests a contact discovery refresh`() = runTest(testDispatcher) { + val viewModel = createViewModel() + + viewModel.onEvent(IndividualSettingsEvent.RecipientRefreshRequested) + + verify(exactly = 1) { repository.refreshRecipient(RECIPIENT_ID) } + } + + @Test + fun `note to self can only be searched -- there is nobody to call, mute, or block`() = runTest(testDispatcher) { + recipientFlow.value = individual(isSelf = true) + + val viewModel = createViewModel(kind = ConversationSettingsKind.NOTE_TO_SELF) + + val callBar = viewModel.state.value.callBar + assertTrue(callBar.isSearchAvailable) + assertFalse(callBar.isMuteAvailable) + assertFalse(callBar.isVideoAvailable) + assertFalse(callBar.isAudioAvailable) + assertFalse(viewModel.state.value.canModifyBlockedState) + } + + @Test + fun `the release notes chat can be muted and blocked but not called`() = runTest(testDispatcher) { + recipientFlow.value = individual(isReleaseNotes = true) + + val viewModel = createViewModel(kind = ConversationSettingsKind.RELEASE_NOTES) + + val callBar = viewModel.state.value.callBar + assertTrue(callBar.isSearchAvailable) + assertTrue(callBar.isMuteAvailable) + assertFalse(callBar.isVideoAvailable) + assertFalse(callBar.isAudioAvailable) + assertTrue(viewModel.state.value.canModifyBlockedState) + } + + @Test + fun `note to self has no groups in common or safety number to load`() = runTest(testDispatcher) { + recipientFlow.value = individual(isSelf = true) + + createViewModel(kind = ConversationSettingsKind.NOTE_TO_SELF) + + verify(exactly = 0) { repository.observeGroupsInCommon(any()) } + coVerify(exactly = 0) { repository.getIdentity(any()) } + } + + @Test + fun `the release notes chat has no groups in common or safety number to load`() = runTest(testDispatcher) { + recipientFlow.value = individual(isReleaseNotes = true) + + createViewModel(kind = ConversationSettingsKind.RELEASE_NOTES) + + verify(exactly = 0) { repository.observeGroupsInCommon(any()) } + coVerify(exactly = 0) { repository.getIdentity(any()) } + } + + @Test + fun `note to self does not offer a contact link`() = runTest(testDispatcher) { + recipientFlow.value = individual(isSelf = true, isSystemContact = true) + + val viewModel = createViewModel(kind = ConversationSettingsKind.NOTE_TO_SELF) + + assertEquals(ContactLinkState.NONE, viewModel.state.value.contactLinkState) + } + + @Test + fun `the release notes chat does not offer a contact link`() = runTest(testDispatcher) { + recipientFlow.value = individual(isReleaseNotes = true, isSystemContact = true) + + val viewModel = createViewModel(kind = ConversationSettingsKind.RELEASE_NOTES) + + assertEquals(ContactLinkState.NONE, viewModel.state.value.contactLinkState) + } + + @Test + fun `release notes help rows open their destinations`() = runTest(testDispatcher) { + recipientFlow.value = individual(isReleaseNotes = true) + val viewModel = createViewModel(kind = ConversationSettingsKind.RELEASE_NOTES) + val actions = collectActions(viewModel) + + viewModel.onEvent(IndividualSettingsEvent.SupportCenterClicked) + viewModel.onEvent(IndividualSettingsEvent.ContactUsClicked) + viewModel.onEvent(IndividualSettingsEvent.DonateClicked) + + assertEquals( + listOf( + ConversationSettingsAction.OpenSupportCenter, + ConversationSettingsAction.OpenContactUs, + ConversationSettingsAction.OpenDonate + ), + actions + ) + } + + private companion object { + val RECIPIENT_ID: RecipientId = RecipientId.from(1L) + const val THREAD_ID = 5L + + fun individual( + isBlocked: Boolean = false, + isSelf: Boolean = false, + isReleaseNotes: Boolean = false, + isSystemContact: Boolean = false, + hasE164: Boolean = false, + shouldShowE164: Boolean = false, + expiresInSeconds: Int = 0, + isMuted: Boolean = false + ): Recipient { + return mockk(relaxed = true) { + every { id } returns RECIPIENT_ID + every { this@mockk.isSelf } returns isSelf + every { this@mockk.isReleaseNotes } returns isReleaseNotes + every { this@mockk.isBlocked } returns isBlocked + every { this@mockk.isSystemContact } returns isSystemContact + every { this@mockk.hasE164 } returns hasE164 + every { this@mockk.shouldShowE164 } returns shouldShowE164 + every { this@mockk.expiresInSeconds } returns expiresInSeconds + every { this@mockk.isMuted } returns isMuted + every { isIndividual } returns (!isSelf && !isReleaseNotes) + every { isGroup } returns false + every { isRegistered } returns true + every { registered } returns RecipientTable.RegisteredState.REGISTERED + } + } + + fun groups(count: Int): List { + return (1..count).map { index -> + mockk(relaxed = true) { + every { id } returns RecipientId.from(100L + index) + } + } + } + + fun mediaRecord(attachment: DatabaseAttachment?): MediaTable.MediaRecord { + return MediaTable.MediaRecord( + attachment = attachment, + recipientId = RECIPIENT_ID, + threadRecipientId = RecipientId.from(2L), + threadId = THREAD_ID, + messageId = 4L, + date = 1000L, + isOutgoing = false + ) + } + + /** + * [Attachment.transferState] is a `@JvmField`, so it can't be stubbed -- we have to build a real attachment and spy + * on the parts of it we do want to control. + */ + fun attachment(transferState: Int, hasUri: Boolean): DatabaseAttachment { + val attachment = spyk(databaseAttachment(transferState)) + every { attachment.uri } returns if (hasUri) mockk() else null + every { attachment.thumbnailUri } returns null + return attachment + } + + fun databaseAttachment(transferState: Int): DatabaseAttachment { + return DatabaseAttachment( + attachmentId = AttachmentId(1L), + mmsId = 1L, + hasData = false, + hasThumbnail = false, + contentType = "image/jpeg", + transferProgress = transferState, + size = 1024L, + fileName = "photo.jpg", + cdn = Cdn.CDN_3, + location = null, + key = null, + digest = null, + incrementalDigest = null, + incrementalMacChunkSize = 0, + fastPreflightId = null, + voiceNote = false, + borderless = false, + videoGif = false, + width = 0, + height = 0, + quote = false, + caption = null, + stickerLocator = null, + blurHash = null, + audioHash = null, + transformProperties = null, + displayOrder = 0, + uploadTimestamp = 0, + dataHash = null, + archiveCdn = null, + thumbnailRestoreState = AttachmentTable.ThumbnailRestoreState.NONE, + archiveTransferState = AttachmentTable.ArchiveTransferState.NONE, + uuid = null, + quoteTargetContentType = null, + metadata = null + ) + } + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Rows.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Rows.kt index 6c35123e41..61520bc177 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/Rows.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Rows.kt @@ -366,6 +366,9 @@ object Rows { /** * Text row that positions [text] and optional [label] in a [TextAndLabel] to the side of an optional [icon]. + * + * Passing [onDisabledClick] keeps the row tappable while `enabled` is false, which rows use to explain why they're + * unavailable rather than ignoring the tap. */ @Composable fun TextRow( @@ -377,6 +380,7 @@ object Rows { foregroundTint: Color = MaterialTheme.colorScheme.onSurface, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, + onDisabledClick: (() -> Unit)? = null, enabled: Boolean = true ) { TextRow( @@ -388,6 +392,7 @@ object Rows { foregroundTint = foregroundTint, onClick = onClick, onLongClick = onLongClick, + onDisabledClick = onDisabledClick, enabled = enabled ) } @@ -405,6 +410,7 @@ object Rows { foregroundTint: Color = MaterialTheme.colorScheme.onSurface, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, + onDisabledClick: (() -> Unit)? = null, enabled: Boolean = true ) { TextRow( @@ -422,7 +428,7 @@ object Rows { painter = icon, contentDescription = null, tint = foregroundTint, - modifier = iconModifier + modifier = iconModifier.alpha(if (enabled) 1f else DISABLED_ALPHA) ) } } else { @@ -431,6 +437,7 @@ object Rows { modifier = modifier, onClick = onClick, onLongClick = onLongClick, + onDisabledClick = onDisabledClick, enabled = enabled ) } @@ -449,6 +456,7 @@ object Rows { iconTint: Color = foregroundTint, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, + onDisabledClick: (() -> Unit)? = null, enabled: Boolean = true ) { TextRow( @@ -466,7 +474,7 @@ object Rows { imageVector = icon, contentDescription = null, tint = iconTint, - modifier = iconModifier + modifier = iconModifier.alpha(if (enabled) 1f else DISABLED_ALPHA) ) } } else { @@ -475,6 +483,7 @@ object Rows { modifier = modifier, onClick = onClick, onLongClick = onLongClick, + onDisabledClick = onDisabledClick, enabled = enabled ) } @@ -490,19 +499,23 @@ object Rows { icon: (@Composable RowScope.() -> Unit)? = null, onClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, + onDisabledClick: (() -> Unit)? = null, enabled: Boolean = true ) { val haptics = LocalHapticFeedback.current + val clickAction = if (enabled) onClick else onDisabledClick + val longClickAction = if (enabled) onLongClick else null + Row( modifier = modifier .fillMaxWidth() .combinedClickable( - enabled = enabled && (onClick != null || onLongClick != null), - onClick = onClick ?: {}, + enabled = clickAction != null || longClickAction != null, + onClick = clickAction ?: {}, onLongClick = { - if (onLongClick != null) { + if (longClickAction != null) { haptics.performHapticFeedback(HapticFeedbackType.LongPress) - onLongClick() + longClickAction() } } ) @@ -648,11 +661,28 @@ private fun ToggleLoadingRowPreview() { @Composable private fun TextRowPreview() { Previews.Preview { - Rows.TextRow( - text = "TextRow", - icon = painterResource(id = android.R.drawable.ic_menu_camera), - onClick = {} - ) + Column { + Rows.TextRow( + text = "TextRow", + icon = painterResource(id = android.R.drawable.ic_menu_camera), + onClick = {} + ) + + Rows.TextRow( + text = "TextRow, disabled", + icon = painterResource(id = android.R.drawable.ic_menu_camera), + enabled = false, + onClick = {} + ) + + // Renders as unavailable but still reports the tap, so it can explain why. + Rows.TextRow( + text = "TextRow, disabled with onDisabledClick", + icon = painterResource(id = android.R.drawable.ic_menu_camera), + enabled = false, + onDisabledClick = {} + ) + } } } diff --git a/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt b/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt index 7d43d3487d..c7ea9a9811 100644 --- a/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt +++ b/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt @@ -11,6 +11,8 @@ import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally @@ -55,6 +57,28 @@ object TransitionSpecs { override val predictivePopTransitionSpec: ContentTransform = Transition.NONE } + /** + * Screens fade and zoom in place, without any directional movement. + */ + object FadeScale : Transition { + private const val DURATION = 200 + private const val SCALE = 0.92f + + override val transitionSpec: ContentTransform = + ( + fadeIn(animationSpec = tween(DURATION)) + + scaleIn(initialScale = SCALE, animationSpec = tween(DURATION)) + ) togetherWith + ( + fadeOut(animationSpec = tween(DURATION)) + + scaleOut(targetScale = SCALE, animationSpec = tween(DURATION)) + ) + + override val popTransitionSpec: ContentTransform = transitionSpec + + override val predictivePopTransitionSpec: ContentTransform = transitionSpec + } + /** * Screens slide in from the right and slide out from the left. */