Add improved notification settings when muted.

This commit is contained in:
Greyson Parrelli
2026-03-02 13:33:53 -05:00
parent 8a36425cac
commit a95ebb2158
30 changed files with 800 additions and 72 deletions

View File

@@ -592,11 +592,19 @@ class ConversationSettingsFragment :
if (!state.recipient.isSelf) {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications),
title = if (RemoteConfig.internalUser) {
DSLSettingsText.from("${getString(R.string.ConversationSettingsFragment__sounds_and_notifications)} (Internal Only)")
} else {
DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications)
},
icon = DSLSettingsIcon.from(R.drawable.symbol_speaker_24),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id)
val action = if (RemoteConfig.internalUser) {
ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment2(state.recipient.id)
} else {
ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id)
}
navController.safeNavigate(action)
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
/**
* Represents all user-driven actions that can occur on the Sounds & Notifications settings screen.
*/
sealed interface SoundsAndNotificationsEvent {
/**
* Mutes notifications for this recipient until the given epoch-millisecond timestamp.
*
* @param muteUntil Epoch-millisecond timestamp after which notifications should resume.
* Use [Long.MAX_VALUE] to mute indefinitely.
*/
data class SetMuteUntil(val muteUntil: Long) : SoundsAndNotificationsEvent
/**
* Clears any active mute, immediately restoring notifications for this recipient.
*/
data object Unmute : SoundsAndNotificationsEvent
/**
* Updates the mention notification setting for this recipient.
* Only relevant for group conversations that support @mentions.
*
* @param setting The new [NotificationSetting] to apply for @mention notifications.
*/
data class SetMentionSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent
/**
* Updates the call notification setting for this recipient.
* Controls whether incoming calls still produce notifications while the conversation is muted.
*
* @param setting The new [NotificationSetting] to apply for call notifications.
*/
data class SetCallNotificationSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent
/**
* Updates the reply notification setting for this recipient.
* Controls whether replies directed at the current user still produce notifications while muted.
*
* @param setting The new [NotificationSetting] to apply for reply notifications.
*/
data class SetReplyNotificationSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent
/**
* Signals that the user tapped the "Custom Notifications" row and wishes to navigate to the
* [custom notifications settings screen][org.thoughtcrime.securesms.components.settings.conversation.sounds.custom.CustomNotificationsSettingsFragment].
*/
data object NavigateToCustomNotifications : SoundsAndNotificationsEvent
}

View File

@@ -82,7 +82,7 @@ class SoundsAndNotificationsSettingsFragment :
)
if (state.hasMentionsSupport) {
val mentionSelection = if (state.mentionSetting == RecipientTable.MentionSetting.ALWAYS_NOTIFY) {
val mentionSelection = if (state.mentionSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY) {
0
} else {
1
@@ -96,9 +96,9 @@ class SoundsAndNotificationsSettingsFragment :
onSelected = {
viewModel.setMentionSetting(
if (it == 0) {
RecipientTable.MentionSetting.ALWAYS_NOTIFY
RecipientTable.NotificationSetting.ALWAYS_NOTIFY
} else {
RecipientTable.MentionSetting.DO_NOT_NOTIFY
RecipientTable.NotificationSetting.DO_NOT_NOTIFY
}
)
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.Navigation
import org.signal.core.ui.compose.ComposeFragment
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class SoundsAndNotificationsSettingsFragment2 : ComposeFragment() {
private val viewModel: SoundsAndNotificationsSettingsViewModel2 by viewModels(
factoryProducer = {
val recipientId = SoundsAndNotificationsSettingsFragment2Args.fromBundle(requireArguments()).recipientId
SoundsAndNotificationsSettingsViewModel2.Factory(recipientId)
}
)
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
if (!state.channelConsistencyCheckComplete || state.recipientId == Recipient.UNKNOWN.id) {
return
}
SoundsAndNotificationsSettingsScreen(
state = state,
formatMuteUntil = { it.formatMutedUntil(requireContext()) },
onEvent = { event ->
when (event) {
is SoundsAndNotificationsEvent.NavigateToCustomNotifications -> {
val action = SoundsAndNotificationsSettingsFragment2Directions
.actionSoundsAndNotificationsSettingsFragment2ToCustomNotificationsSettingsFragment(state.recipientId)
Navigation.findNavController(requireView()).safeNavigate(action)
}
else -> viewModel.onEvent(event)
}
},
onNavigationClick = {
requireActivity().onBackPressedDispatcher.onBackPressed()
},
onMuteClick = {
MuteDialog.show(requireContext(), childFragmentManager, viewLifecycleOwner) { muteUntil ->
viewModel.onEvent(SoundsAndNotificationsEvent.SetMuteUntil(muteUntil))
}
}
)
}
}

View File

@@ -25,7 +25,7 @@ class SoundsAndNotificationsSettingsRepository(private val context: Context) {
}
}
fun setMentionSetting(recipientId: RecipientId, mentionSetting: RecipientTable.MentionSetting) {
fun setMentionSetting(recipientId: RecipientId, mentionSetting: RecipientTable.NotificationSetting) {
SignalExecutors.BOUNDED.execute {
SignalDatabase.recipients.setMentionSetting(recipientId, mentionSetting)
}

View File

@@ -0,0 +1,309 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
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.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Texts
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
import org.signal.core.ui.R as CoreUiR
@Composable
fun SoundsAndNotificationsSettingsScreen(
state: SoundsAndNotificationsSettingsState2,
formatMuteUntil: (Long) -> String,
onEvent: (SoundsAndNotificationsEvent) -> Unit,
onNavigationClick: () -> Unit,
onMuteClick: () -> Unit
) {
val isMuted = state.muteUntil > 0
var showUnmuteDialog by remember { mutableStateOf(false) }
Scaffolds.Settings(
title = stringResource(R.string.ConversationSettingsFragment__sounds_and_notifications),
onNavigationClick = onNavigationClick,
navigationIcon = SignalIcons.ArrowStart.imageVector,
navigationContentDescription = stringResource(R.string.CallScreenTopBar__go_back)
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
// Custom notifications
item {
val summary = if (state.hasCustomNotificationSettings) {
stringResource(R.string.preferences_on)
} else {
stringResource(R.string.preferences_off)
}
Rows.TextRow(
text = stringResource(R.string.SoundsAndNotificationsSettingsFragment__custom_notifications),
label = summary,
icon = painterResource(R.drawable.ic_speaker_24),
onClick = { onEvent(SoundsAndNotificationsEvent.NavigateToCustomNotifications) }
)
}
// Mute
item {
val muteSummary = if (isMuted) {
formatMuteUntil(state.muteUntil)
} else {
stringResource(R.string.SoundsAndNotificationsSettingsFragment__not_muted)
}
val muteIcon = if (isMuted) {
R.drawable.ic_bell_disabled_24
} else {
R.drawable.ic_bell_24
}
Rows.TextRow(
text = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mute_notifications),
label = muteSummary,
icon = painterResource(muteIcon),
onClick = {
if (isMuted) showUnmuteDialog = true else onMuteClick()
}
)
}
// Divider + When muted section
item {
Dividers.Default()
}
item {
Texts.SectionHeader(text = stringResource(R.string.SoundsAndNotificationsSettingsFragment__when_muted))
}
// Calls
item {
NotificationSettingRow(
title = stringResource(R.string.SoundsAndNotificationsSettingsFragment__calls),
dialogTitle = stringResource(R.string.SoundsAndNotificationsSettingsFragment__calls),
dialogMessage = stringResource(R.string.SoundsAndNotificationsSettingsFragment__calls_dialog_message),
icon = painterResource(CoreUiR.drawable.symbol_phone_24),
setting = state.callNotificationSetting,
onSelected = { onEvent(SoundsAndNotificationsEvent.SetCallNotificationSetting(it)) }
)
}
// Mentions (only for groups)
if (state.hasMentionsSupport) {
item {
NotificationSettingRow(
title = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mentions),
dialogTitle = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mentions),
dialogMessage = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mentions_dialog_message),
icon = painterResource(R.drawable.ic_at_24),
setting = state.mentionSetting,
onSelected = { onEvent(SoundsAndNotificationsEvent.SetMentionSetting(it)) }
)
}
}
// Replies (only for groups)
if (state.hasMentionsSupport) {
item {
NotificationSettingRow(
title = stringResource(R.string.SoundsAndNotificationsSettingsFragment__replies_to_you),
dialogTitle = stringResource(R.string.SoundsAndNotificationsSettingsFragment__replies_to_you),
dialogMessage = stringResource(R.string.SoundsAndNotificationsSettingsFragment__replies_dialog_message),
icon = painterResource(R.drawable.symbol_reply_24),
setting = state.replyNotificationSetting,
onSelected = { onEvent(SoundsAndNotificationsEvent.SetReplyNotificationSetting(it)) }
)
}
}
}
}
if (showUnmuteDialog) {
Dialogs.SimpleAlertDialog(
title = Dialogs.NoTitle,
body = formatMuteUntil(state.muteUntil),
confirm = stringResource(R.string.ConversationSettingsFragment__unmute),
dismiss = stringResource(android.R.string.cancel),
onConfirm = { onEvent(SoundsAndNotificationsEvent.Unmute) },
onDismiss = { showUnmuteDialog = false }
)
}
}
@Composable
private fun NotificationSettingRow(
title: String,
dialogTitle: String,
dialogMessage: String,
icon: Painter,
setting: NotificationSetting,
onSelected: (NotificationSetting) -> Unit
) {
var showDialog by remember { mutableStateOf(false) }
val labels = arrayOf(
stringResource(R.string.SoundsAndNotificationsSettingsFragment__always_notify),
stringResource(R.string.SoundsAndNotificationsSettingsFragment__do_not_notify)
)
val selectedLabel = if (setting == NotificationSetting.ALWAYS_NOTIFY) labels[0] else labels[1]
Rows.TextRow(
text = title,
label = selectedLabel,
icon = icon,
onClick = { showDialog = true }
)
if (showDialog) {
NotificationSettingDialog(
title = dialogTitle,
message = dialogMessage,
labels = labels,
selectedIndex = if (setting == NotificationSetting.ALWAYS_NOTIFY) 0 else 1,
onDismiss = { showDialog = false },
onSelected = { index ->
onSelected(if (index == 0) NotificationSetting.ALWAYS_NOTIFY else NotificationSetting.DO_NOT_NOTIFY)
showDialog = false
}
)
}
}
@Composable
private fun NotificationSettingDialog(
title: String,
message: String,
labels: Array<String>,
selectedIndex: Int,
onDismiss: () -> Unit,
onSelected: (Int) -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = AlertDialogDefaults.shape,
color = SignalTheme.colors.colorSurface2
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.padding(top = 24.dp)
.horizontalGutters()
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(top = 8.dp)
.horizontalGutters()
)
Column(modifier = Modifier.padding(top = 16.dp, bottom = 16.dp)) {
labels.forEachIndexed { index, label ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 48.dp)
.clickable { onSelected(index) }
.horizontalGutters()
) {
RadioButton(
selected = index == selectedIndex,
onClick = { onSelected(index) }
)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
}
}
}
}
@DayNightPreviews
@Composable
private fun SoundsAndNotificationsSettingsScreenMutedPreview() {
Previews.Preview {
SoundsAndNotificationsSettingsScreen(
state = SoundsAndNotificationsSettingsState2(
muteUntil = Long.MAX_VALUE,
callNotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
mentionSetting = NotificationSetting.ALWAYS_NOTIFY,
replyNotificationSetting = NotificationSetting.DO_NOT_NOTIFY,
hasMentionsSupport = true,
hasCustomNotificationSettings = false,
channelConsistencyCheckComplete = true
),
formatMuteUntil = { "Always" },
onEvent = {},
onNavigationClick = {},
onMuteClick = {}
)
}
}
@DayNightPreviews
@Composable
private fun SoundsAndNotificationsSettingsScreenUnmutedPreview() {
Previews.Preview {
SoundsAndNotificationsSettingsScreen(
state = SoundsAndNotificationsSettingsState2(
muteUntil = 0L,
callNotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
mentionSetting = NotificationSetting.ALWAYS_NOTIFY,
replyNotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
hasMentionsSupport = false,
hasCustomNotificationSettings = true,
channelConsistencyCheckComplete = true
),
formatMuteUntil = { "" },
onEvent = {},
onNavigationClick = {},
onMuteClick = {}
)
}
}

View File

@@ -7,7 +7,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
data class SoundsAndNotificationsSettingsState(
val recipientId: RecipientId = Recipient.UNKNOWN.id,
val muteUntil: Long = 0L,
val mentionSetting: RecipientTable.MentionSetting = RecipientTable.MentionSetting.DO_NOT_NOTIFY,
val mentionSetting: RecipientTable.NotificationSetting = RecipientTable.NotificationSetting.DO_NOT_NOTIFY,
val hasCustomNotificationSettings: Boolean = false,
val hasMentionsSupport: Boolean = false,
val channelConsistencyCheckComplete: Boolean = false

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
data class SoundsAndNotificationsSettingsState2(
val recipientId: RecipientId = Recipient.UNKNOWN.id,
val muteUntil: Long = 0L,
val mentionSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
val callNotificationSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
val replyNotificationSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
val hasCustomNotificationSettings: Boolean = false,
val hasMentionsSupport: Boolean = false,
val channelConsistencyCheckComplete: Boolean = false
) {
val isMuted = muteUntil > 0
}

View File

@@ -38,7 +38,7 @@ class SoundsAndNotificationsSettingsViewModel(
repository.setMuteUntil(recipientId, 0L)
}
fun setMentionSetting(mentionSetting: RecipientTable.MentionSetting) {
fun setMentionSetting(mentionSetting: RecipientTable.NotificationSetting) {
repository.setMentionSetting(recipientId, mentionSetting)
}

View File

@@ -0,0 +1,102 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver
import org.thoughtcrime.securesms.recipients.RecipientId
class SoundsAndNotificationsSettingsViewModel2(
private val recipientId: RecipientId
) : ViewModel(), RecipientForeverObserver {
private val _state = MutableStateFlow(SoundsAndNotificationsSettingsState2())
val state: StateFlow<SoundsAndNotificationsSettingsState2> = _state
private val liveRecipient = Recipient.live(recipientId)
init {
liveRecipient.observeForever(this)
onRecipientChanged(liveRecipient.get())
viewModelScope.launch(Dispatchers.IO) {
if (NotificationChannels.supported()) {
NotificationChannels.getInstance().ensureCustomChannelConsistency()
}
_state.update { it.copy(channelConsistencyCheckComplete = true) }
}
}
override fun onRecipientChanged(recipient: Recipient) {
_state.update {
it.copy(
recipientId = recipientId,
muteUntil = if (recipient.isMuted) recipient.muteUntil else 0L,
mentionSetting = recipient.mentionSetting,
callNotificationSetting = recipient.callNotificationSetting,
replyNotificationSetting = recipient.replyNotificationSetting,
hasMentionsSupport = recipient.isPushV2Group,
hasCustomNotificationSettings = recipient.notificationChannel != null || !NotificationChannels.supported()
)
}
}
override fun onCleared() {
liveRecipient.removeForeverObserver(this)
}
fun onEvent(event: SoundsAndNotificationsEvent) {
when (event) {
is SoundsAndNotificationsEvent.SetMuteUntil -> applySetMuteUntil(event.muteUntil)
is SoundsAndNotificationsEvent.Unmute -> applySetMuteUntil(0L)
is SoundsAndNotificationsEvent.SetMentionSetting -> applySetMentionSetting(event.setting)
is SoundsAndNotificationsEvent.SetCallNotificationSetting -> applySetCallNotificationSetting(event.setting)
is SoundsAndNotificationsEvent.SetReplyNotificationSetting -> applySetReplyNotificationSetting(event.setting)
is SoundsAndNotificationsEvent.NavigateToCustomNotifications -> Unit // Navigation handled by UI
}
}
private fun applySetMuteUntil(muteUntil: Long) {
viewModelScope.launch(Dispatchers.IO) {
SignalDatabase.recipients.setMuted(recipientId, muteUntil)
}
}
private fun applySetMentionSetting(setting: NotificationSetting) {
viewModelScope.launch(Dispatchers.IO) {
SignalDatabase.recipients.setMentionSetting(recipientId, setting)
}
}
private fun applySetCallNotificationSetting(setting: NotificationSetting) {
viewModelScope.launch(Dispatchers.IO) {
SignalDatabase.recipients.setCallNotificationSetting(recipientId, setting)
}
}
private fun applySetReplyNotificationSetting(setting: NotificationSetting) {
viewModelScope.launch(Dispatchers.IO) {
SignalDatabase.recipients.setReplyNotificationSetting(recipientId, setting)
}
}
class Factory(private val recipientId: RecipientId) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(SoundsAndNotificationsSettingsViewModel2(recipientId)))
}
}
}