mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-15 01:33:25 +01:00
Convert ConversationSettingsFragment to Compose.
This commit is contained in:
@@ -57,7 +57,9 @@ fun EntryProviderScope<NavKey>.chatsNavEntries(
|
||||
MessageDetailsEntry(route)
|
||||
}
|
||||
|
||||
entry<MainNavigationDetailLocation.Chats.ConversationSettings> { route ->
|
||||
entry<MainNavigationDetailLocation.Chats.ConversationSettings>(
|
||||
metadata = TransitionSpecs.FadeScale.metadata
|
||||
) { route ->
|
||||
ConversationSettingsEntry(route)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+102
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+217
@@ -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<RecipientId>) : 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<RecipientId>) : 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<Recipient>) : 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
|
||||
}
|
||||
+7
-6
@@ -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()
|
||||
|
||||
-38
@@ -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<RecipientId>
|
||||
) : ConversationSettingsEvent()
|
||||
|
||||
class AddMembersToGroup(
|
||||
val groupId: GroupId,
|
||||
val selectionLimits: SelectionLimits,
|
||||
val groupMembersWithoutSelf: List<RecipientId>
|
||||
) : ConversationSettingsEvent()
|
||||
|
||||
object ShowGroupHardLimitDialog : ConversationSettingsEvent()
|
||||
|
||||
class ShowAddMembersToGroupError(
|
||||
val failureReason: GroupChangeFailureReason
|
||||
) : ConversationSettingsEvent()
|
||||
|
||||
class ShowBlockGroupError(
|
||||
val failureReason: GroupChangeFailureReason
|
||||
) : ConversationSettingsEvent()
|
||||
|
||||
class ShowGroupInvitesSentDialog(
|
||||
val invitesSentTo: List<Recipient>
|
||||
) : ConversationSettingsEvent()
|
||||
|
||||
class ShowMembersAdded(
|
||||
val membersAddedCount: Int
|
||||
) : ConversationSettingsEvent()
|
||||
}
|
||||
+439
-1147
File diff suppressed because it is too large
Load Diff
+32
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -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())
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
|
||||
+349
-151
@@ -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<List<Pair<CallTable.Call, MessageRecord>>> {
|
||||
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<Recipient> {
|
||||
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<Recipient> {
|
||||
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<GroupDetails> {
|
||||
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<StoryViewState> {
|
||||
return StoryViewState.getForRecipientId(recipientId).asFlow()
|
||||
}
|
||||
|
||||
fun observeStoryViewState(groupId: GroupId): Flow<StoryViewState> {
|
||||
return flow {
|
||||
val recipientId = withContext(SignalDispatchers.Default) { Recipient.externalGroupExact(groupId).id }
|
||||
emitAll(observeStoryViewState(recipientId))
|
||||
}
|
||||
}
|
||||
|
||||
fun getStoryViewState(groupId: GroupId): Observable<StoryViewState> {
|
||||
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<List<Recipient>> {
|
||||
fun observeGroupsInCommon(recipientId: RecipientId): Flow<List<Recipient>> {
|
||||
return GroupsInCommonRepository.getGroupsInCommon(context, recipientId)
|
||||
.asObservable()
|
||||
}
|
||||
|
||||
fun getGroupMembership(recipientId: RecipientId, consumer: (List<RecipientId>) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val groupDatabase = SignalDatabase.groups
|
||||
val groupRecords = groupDatabase.getPushGroupsContainingMember(recipientId)
|
||||
val groupRecipients = ArrayList<RecipientId>(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<List<CallEntry>> {
|
||||
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<Long>, callRowIds: LongArray): Flow<List<CallEntry>> {
|
||||
return threadIdFlow
|
||||
.distinctUntilChanged()
|
||||
.filter { it > 0 }
|
||||
.flatMapLatest { observeCalls(it, callRowIds) }
|
||||
}
|
||||
|
||||
suspend fun getCallEntries(callRowIds: LongArray): List<CallEntry> {
|
||||
if (callRowIds.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return withContext(SignalDispatchers.Default) {
|
||||
val callMap: Map<Long, CallTable.Call> = 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<MediaTable.MediaRecord> {
|
||||
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<RecipientId> {
|
||||
return withContext(SignalDispatchers.Default) {
|
||||
SignalDatabase.groups.getPushGroupsContainingMember(recipientId).map { it.recipientId }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMemberLabels(groupId: GroupId.V2, members: List<Recipient>): Map<RecipientId, StyledMemberLabel> {
|
||||
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<RecipientId> = decryptedGroup.pendingMembers
|
||||
.map { m -> m.serviceIdBytes }
|
||||
.map { s -> GroupProtoUtil.serviceIdBinaryToRecipientId(s) }
|
||||
|
||||
val members = mutableListOf<RecipientId>()
|
||||
|
||||
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<RecipientId>, 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<RecipientId> = 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<RecipientId>): 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<String> {
|
||||
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<Unit, GroupChangeFailureReason> {
|
||||
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<GroupMember>,
|
||||
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<GroupMember> = GroupMemberOrder.comparator(
|
||||
{ it.recipient.isSelf },
|
||||
{ it.isAdmin },
|
||||
{ it.recipient.hasAUserSetDisplayName(AppDependencies.application) },
|
||||
{ it.recipient.getDisplayName(AppDependencies.application) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
-110
@@ -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<MediaTable.MediaRecord> = emptyList(),
|
||||
val sharedMediaIds: List<Long> = listOf(),
|
||||
val displayInternalRecipientDetails: Boolean = false,
|
||||
val calls: List<CallPreference.Model> = 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<Recipient> = listOf(),
|
||||
val groupsInCommon: List<Recipient> = 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<GroupMemberEntry.FullMember> = listOf(),
|
||||
val members: List<GroupMemberEntry.FullMember> = 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<RecipientId, MemberLabel> = 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
|
||||
}
|
||||
-601
@@ -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<ConversationSettingsEvent> = PublishSubject.create()
|
||||
|
||||
private val sharedMediaUpdateTrigger = MutableLiveData(Unit)
|
||||
|
||||
val state: LiveData<ConversationSettingsState> = store.stateLiveData
|
||||
val events: Observable<ConversationSettingsEvent> = internalEvents.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
protected val disposable = CompositeDisposable()
|
||||
|
||||
init {
|
||||
val threadId: LiveData<Long> = state.map { it.threadId }.distinctUntilChanged()
|
||||
val updater: LiveData<Long> = LiveDataUtil.combineLatest(threadId, sharedMediaUpdateTrigger) { tId, _ -> tId }
|
||||
|
||||
val sharedMedia: LiveData<List<MediaTable.MediaRecord>> = 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<Unit> {
|
||||
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<Result<Unit, GroupChangeFailureReason>> {
|
||||
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<RecipientId>, 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<RecipientId>, 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<Boolean> = LiveDataUtil.mapAsync(liveGroup.groupRecipient) { r -> repository.isMessageRequestAccepted(r) }
|
||||
val descriptionState: LiveData<DescriptionState> = 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<RecipientId>, 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<GroupMemberEntry.FullMember>) = 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 <T : ViewModel> create(modelClass: Class<T>): 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
|
||||
)
|
||||
}
|
||||
+181
@@ -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<RecipientId>) : 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<RecipientId, StyledMemberLabel>, 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<MediaTable.MediaRecord>) : 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<CallEntry>) : 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
|
||||
}
|
||||
+743
@@ -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<GroupMember> = 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))
|
||||
}
|
||||
+94
@@ -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<MediaTable.MediaRecord> = emptyList(),
|
||||
val sharedMediaLoaded: Boolean = false,
|
||||
val calls: List<CallEntry> = emptyList(),
|
||||
val callBar: CallBarState = CallBarState(),
|
||||
val title: String = "",
|
||||
val description: String? = null,
|
||||
val descriptionShouldLinkify: Boolean = false,
|
||||
val membershipCountDescription: String = "",
|
||||
val allMembers: List<GroupMember> = emptyList(),
|
||||
val membersExpanded: Boolean = false,
|
||||
val memberLabels: Map<RecipientId, StyledMemberLabel> = 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<GroupMember> = 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
|
||||
}
|
||||
}
|
||||
+443
@@ -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<GroupSettingsEvent>(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<ConversationSettingsAction>(Channel.BUFFERED)
|
||||
|
||||
val state: StateFlow<GroupSettingsState> = _state.asStateFlow()
|
||||
val actions: Flow<ConversationSettingsAction> = _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<Recipient>) {
|
||||
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 <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(GroupSettingsViewModel(groupId, callMessageIds, repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -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<MediaTable.MediaRecord>) : 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<CallEntry>) : 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<Recipient>) : 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})"
|
||||
}
|
||||
}
|
||||
+382
@@ -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<Badge>,
|
||||
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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+61
@@ -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<Recipient> = emptyList(),
|
||||
val selfHasGroups: Boolean = false,
|
||||
val groupsInCommonExpanded: Boolean = false,
|
||||
val sharedMedia: List<MediaTable.MediaRecord> = emptyList(),
|
||||
val sharedMediaLoaded: Boolean = false,
|
||||
val calls: List<CallEntry> = 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<Recipient> = 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
|
||||
}
|
||||
+303
@@ -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<IndividualSettingsEvent>(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<ConversationSettingsAction>(Channel.BUFFERED)
|
||||
|
||||
val state: StateFlow<IndividualSettingsState> = _state.asStateFlow()
|
||||
val actions: Flow<ConversationSettingsAction> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(IndividualSettingsViewModel(recipientId, kind, callMessageIds, repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+207
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
-71
@@ -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<Model>() {
|
||||
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<Model>(itemView) {
|
||||
private val avatar: AvatarView = itemView.findViewById<AvatarView>(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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
-144
@@ -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<T : BioTextPreferenceModel<T>> : PreferenceModel<T>() {
|
||||
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<RecipientModel>() {
|
||||
|
||||
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<GroupModel>() {
|
||||
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<T : BioTextPreferenceModel<T>>(itemView: View) : MappingViewHolder<T>(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<RecipientModel>(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<GroupModel>(itemView)
|
||||
}
|
||||
-133
@@ -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<Model> {
|
||||
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<Model, ConversationSettingsCallPreferenceItemBinding>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-66
@@ -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<Model>() {
|
||||
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<Model>(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-58
@@ -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<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return state == newItem.state
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(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
|
||||
}
|
||||
}
|
||||
-49
@@ -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<MediaTable.MediaRecord>,
|
||||
val mediaIds: List<Long>,
|
||||
val onMediaRecordClick: (View, MediaTable.MediaRecord, Boolean) -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
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<Model>(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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -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
|
||||
}
|
||||
}
|
||||
+327
@@ -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)
|
||||
)
|
||||
+79
@@ -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<CallEntry>) {
|
||||
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
|
||||
)
|
||||
}
|
||||
+23
@@ -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 <T> collapse(all: List<T>, expanded: Boolean): List<T> {
|
||||
return if (canExpand(all, expanded)) all.take(COLLAPSED_COUNT) else all
|
||||
}
|
||||
}
|
||||
+205
@@ -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 = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
+339
@@ -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<Badge> = 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<Badge>,
|
||||
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
+137
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
)
|
||||
+13
@@ -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
|
||||
}
|
||||
+45
@@ -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)) }
|
||||
+35
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -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<Unit>(replay = 1).apply { tryEmit(Unit) }
|
||||
private val threadId = MutableStateFlow<Long?>(null)
|
||||
|
||||
fun onThreadIdLoaded(threadId: Long) {
|
||||
this.threadId.value = threadId
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
refreshTrigger.tryEmit(Unit)
|
||||
}
|
||||
|
||||
fun observe(): Flow<List<MediaTable.MediaRecord>> {
|
||||
return combine(threadId.filterNotNull().distinctUntilChanged(), refreshTrigger) { id, _ -> id }
|
||||
.map { repository.getSharedMedia(it, SHARED_MEDIA_LIMIT) }
|
||||
}
|
||||
}
|
||||
+88
@@ -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<MediaTable.MediaRecord>,
|
||||
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<MediaTable.MediaRecord>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
+33
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -62,11 +62,12 @@ public final class LiveGroup {
|
||||
this.requestingMembers = mapToRequestingMembers(this.groupRecord);
|
||||
|
||||
if (groupId.isV2()) {
|
||||
LiveData<GroupTable.V2GroupProperties> 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);
|
||||
|
||||
+5
-5
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:viewBindingIgnore="true"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal">
|
||||
|
||||
<org.thoughtcrime.securesms.avatar.view.AvatarView
|
||||
android:id="@+id/bio_preference_avatar"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="80dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
android:id="@+id/bio_preference_badge"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_marginStart="44dp"
|
||||
android:layout_marginTop="52dp"
|
||||
android:contentDescription="@string/ImageView__badge"
|
||||
app:badge_size="large"
|
||||
app:layout_constraintStart_toStartOf="@id/bio_preference_avatar"
|
||||
app:layout_constraintTop_toTopOf="@id/bio_preference_avatar" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</FrameLayout>
|
||||
@@ -1,75 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
tools:viewBindingIgnore="true"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
android:id="@+id/bio_preference_headline"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="32dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="32dp"
|
||||
android:gravity="center"
|
||||
android:textAppearance="@style/Signal.Text.HeadlineMedium"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Miles Morales" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bio_preference_terminated_pill"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:background="@drawable/terminated_pill_background"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingBottom="6dp"
|
||||
android:textAppearance="@style/Signal.Text.BodyMedium"
|
||||
android:textColor="@color/signal_colorOnSurface"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/bio_preference_headline"
|
||||
tools:text="This group was ended."
|
||||
tools:visibility="visible" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
android:id="@+id/bio_preference_subhead_1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="32dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="32dp"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="4sp"
|
||||
android:textAppearance="@style/Signal.Text.BodyLarge"
|
||||
android:textColor="@color/signal_text_secondary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/bio_preference_terminated_pill"
|
||||
tools:text=":-) Just hanging around." />
|
||||
|
||||
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
android:id="@+id/bio_preference_subhead_2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="32dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="32dp"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="4sp"
|
||||
android:textAppearance="@style/Signal.Text.BodyLarge"
|
||||
android:textColor="@color/signal_text_secondary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/bio_preference_subhead_1"
|
||||
tools:text="+1 555-876-5309" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="72dp"
|
||||
android:paddingHorizontal="@dimen/dsl_settings_gutter"
|
||||
android:paddingVertical="14dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:id="@+id/call_icon"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:tint="@color/signal_colorOnSurface"
|
||||
tools:srcCompat="@drawable/ic_update_video_call_missed_16" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/call_type"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:textAppearance="@style/Signal.Text.BodyLarge"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_time"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/call_icon"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
tools:text="Incoming video call" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/call_time"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:textAppearance="@style/Signal.Text.BodyMedium"
|
||||
android:textColor="@color/signal_colorOnSurfaceVariant"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/call_icon"
|
||||
app:layout_constraintTop_toBottomOf="@id/call_type"
|
||||
tools:text="10:00 AM" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.ExpirationTimerView
|
||||
android:id="@+id/call_timer"
|
||||
android:layout_width="12dp"
|
||||
android:layout_height="12dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:tint="@color/signal_colorOnSurfaceVariant"
|
||||
app:layout_constraintTop_toTopOf="@id/call_time"
|
||||
app:layout_constraintBottom_toBottomOf="@id/call_time"
|
||||
app:layout_constraintStart_toEndOf="@id/call_time" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:viewBindingIgnore="true"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:orientation="vertical"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/toolbar" />
|
||||
|
||||
<include layout="@layout/conversation_settings_toolbar" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<org.thoughtcrime.securesms.components.emoji.EmojiTextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:viewBindingIgnore="true"
|
||||
android:id="@+id/manage_group_description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_horizontal"
|
||||
android:maxLines="2"
|
||||
android:textAppearance="@style/Signal.Text.BodyLarge"
|
||||
android:textColor="@color/signal_text_secondary"
|
||||
tools:text="Group to plot the capture of Doc Oct" />
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<org.thoughtcrime.securesms.util.views.LearnMoreTextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:viewBindingIgnore="true"
|
||||
android:id="@+id/manage_group_info_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="@dimen/dsl_settings_gutter"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="@dimen/dsl_settings_gutter"
|
||||
android:text="@string/ManageGroupActivity_legacy_group_upgrade"
|
||||
android:textAlignment="center"
|
||||
android:textAppearance="@style/Signal.Text.Body"
|
||||
android:textColor="@color/signal_text_secondary" />
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<org.thoughtcrime.securesms.components.ThreadPhotoRailView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:viewBindingIgnore="true"
|
||||
android:id="@+id/rail_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp" />
|
||||
@@ -1,79 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
tools:viewBindingIgnore="true"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
|
||||
|
||||
<View
|
||||
android:id="@+id/toolbar_background"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:alpha="0"
|
||||
android:background="@color/signal_colorSurface2"
|
||||
app:layout_constraintBottom_toBottomOf="@id/toolbar"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/toolbar" />
|
||||
|
||||
<org.thoughtcrime.securesms.util.views.DarkOverflowToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/signal_m3_toolbar_height"
|
||||
android:minHeight="@dimen/signal_m3_toolbar_height"
|
||||
android:theme="?attr/settingsToolbarStyle"
|
||||
app:contentInsetStartWithNavigation="60dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:navigationIcon="@drawable/symbol_arrow_start_24">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="start">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/toolbar_avatar_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:alpha="0"
|
||||
android:translationY="56dp"
|
||||
tools:alpha="1"
|
||||
tools:translationY="0dp">
|
||||
|
||||
<org.thoughtcrime.securesms.components.AvatarImageView
|
||||
android:id="@+id/toolbar_avatar"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
app:fallbackImageSize="small" />
|
||||
|
||||
<org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
android:id="@+id/toolbar_badge"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginTop="23dp"
|
||||
app:badge_size="small" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
android:id="@+id/toolbar_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="16dp"
|
||||
android:alpha="0"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="@style/Signal.Text.TitleLarge"
|
||||
android:translationY="56dp"
|
||||
tools:alpha="1"
|
||||
tools:text="Miles Morales"
|
||||
tools:translationY="0dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</org.thoughtcrime.securesms.util.views.DarkOverflowToolbar>
|
||||
</merge>
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<item
|
||||
android:id="@+id/action_edit"
|
||||
android:icon="@drawable/ic_compose_tinted_24"
|
||||
android:title="@string/ManageGroupActivity_edit_name_and_picture"
|
||||
android:visible="false"
|
||||
app:showAsAction="always" />
|
||||
</menu>
|
||||
@@ -52,6 +52,10 @@
|
||||
app:argType="long[]"
|
||||
app:nullable="true" />
|
||||
|
||||
<argument
|
||||
android:name="kind"
|
||||
app:argType="org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsKind" />
|
||||
|
||||
<action
|
||||
android:id="@+id/action_conversationSettingsFragment_to_soundsAndNotificationsSettingsFragment"
|
||||
app:destination="@id/soundsAndNotificationsSettingsFragment"
|
||||
|
||||
@@ -6212,6 +6212,8 @@
|
||||
<string name="ConversationSettingsFragment__end_group">End group</string>
|
||||
<!-- Banner shown at the top of group settings when the group has been ended -->
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">This group has ended.</string>
|
||||
<!-- Subhead under a legacy group's name, joining the member count and the "Legacy Group" label. %1$s is the member count, %2$s is the label -->
|
||||
<string name="ConversationSettingsFragment__s_dot_s">%1$s · %2$s</string>
|
||||
<!-- Archive chat option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__archive_chat">Archive chat</string>
|
||||
<!-- Delete chat option in group conversation settings screen -->
|
||||
|
||||
+164
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
-51
@@ -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)
|
||||
}
|
||||
}
|
||||
+88
@@ -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)
|
||||
}
|
||||
}
|
||||
+711
@@ -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<ConversationSettingsRepository>(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<Flow<Long>>(), 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<ConversationSettingsAction> {
|
||||
val actions = mutableListOf<ConversationSettingsAction>()
|
||||
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<Recipient>(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<ConversationSettingsAction>(), 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<ConversationSettingsAction>(), 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<ConversationSettingsAction>(), 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<ConversationSettingsAction>(), 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<GroupMember> = 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<Recipient>(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<GroupMember> {
|
||||
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<Recipient>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+841
@@ -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<ConversationSettingsRepository>(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<Flow<Long>>(), 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<ConversationSettingsAction> {
|
||||
val actions = mutableListOf<ConversationSettingsAction>()
|
||||
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<IdentityRecord>()
|
||||
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<Badge>()
|
||||
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<IdentityRecord>()
|
||||
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<ConversationSettingsAction>(), 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<ConversationSettingsAction>(), 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<ConversationSettingsAction>(), 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<Recipient>(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<Recipient> {
|
||||
return (1..count).map { index ->
|
||||
mockk<Recipient>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user