Convert ConversationSettingsFragment to Compose.

This commit is contained in:
Greyson Parrelli
2026-08-10 16:32:54 -04:00
parent 25bd414cbf
commit 09caf6b63f
67 changed files with 7482 additions and 2969 deletions
@@ -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
)
}
}
@@ -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
}
}
}
}
@@ -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
}
@@ -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()
@@ -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()
}
@@ -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
}
}
}
}
@@ -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())
@@ -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)
}
@@ -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) }
)
}
}
@@ -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
}
@@ -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
)
}
@@ -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
}
@@ -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))
}
@@ -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
}
}
@@ -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)))
}
}
}
@@ -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})"
}
}
@@ -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 = {}
)
}
}
@@ -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
}
@@ -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)))
}
}
}
@@ -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 = {}
)
}
}
@@ -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 = {}
)
}
}
@@ -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) }
}
}
}
@@ -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)
}
@@ -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)
}
}
}
@@ -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()
}
}
}
}
}
@@ -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
}
}
@@ -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))
}
}
}
}
@@ -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
}
}
@@ -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)
)
@@ -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
)
}
@@ -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
}
}
@@ -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 = {})
}
}
}
@@ -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"
)
}
}
@@ -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 = {}
)
}
}
}
@@ -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
)
}
}
@@ -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
)
@@ -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
}
@@ -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)) }
@@ -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)
}
}
}
@@ -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) }
}
}
@@ -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)
}
}
@@ -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);
@@ -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"
+2
View File
@@ -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 -->
@@ -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
)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
)
}
}
}
@@ -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.
*/