Add custom mute until option.

This commit is contained in:
Greyson Parrelli
2026-02-25 10:06:15 -05:00
committed by Cody Henthorne
parent 7d1897a9d2
commit 121f0c6134
13 changed files with 486 additions and 83 deletions

View File

@@ -38,7 +38,6 @@ import org.signal.core.util.orNull
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.AvatarPreviewActivity
import org.thoughtcrime.securesms.BlockUnblockDialog
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.PushContactSelectionActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
@@ -119,10 +118,11 @@ private const val REQUEST_CODE_ADD_CONTACT = 2
private const val REQUEST_CODE_ADD_MEMBERS_TO_GROUP = 3
private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4
class ConversationSettingsFragment : DSLSettingsFragment(
layoutId = R.layout.conversation_settings_fragment,
menuId = R.menu.conversation_settings
) {
class ConversationSettingsFragment :
DSLSettingsFragment(
layoutId = R.layout.conversation_settings_fragment,
menuId = R.menu.conversation_settings
) {
private val args: ConversationSettingsFragmentArgs by navArgs()
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
@@ -469,9 +469,11 @@ class ConversationSettingsFragment : DSLSettingsFragment(
YouAreAlreadyInACallSnackbar.show(requireView())
}
},
onMuteClick = {
onMuteClick = { view ->
if (!state.buttonStripState.isMuted) {
MuteDialog.show(requireContext(), viewModel::setMuteUntil)
MuteContextMenu.show(view, requireView() as ViewGroup, childFragmentManager, viewLifecycleOwner) { duration ->
viewModel.setMuteUntil(duration)
}
} else {
MaterialAlertDialogBuilder(requireContext())
.setMessage(state.recipient.muteUntil.formatMutedUntil(requireContext()))

View File

@@ -0,0 +1,49 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import java.util.concurrent.TimeUnit
object MuteContextMenu {
@JvmStatic
fun show(anchor: View, container: ViewGroup, fragmentManager: FragmentManager, lifecycleOwner: LifecycleOwner, action: (Long) -> Unit): SignalContextMenu {
fragmentManager.setFragmentResultListener(MuteUntilTimePickerBottomSheet.REQUEST_KEY, lifecycleOwner) { _, bundle ->
action(bundle.getLong(MuteUntilTimePickerBottomSheet.RESULT_TIMESTAMP))
}
val context = anchor.context
val actionItems = listOf(
ActionItem(R.drawable.ic_daytime_24, context.getString(R.string.arrays__mute_for_one_hour)) {
action(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1))
},
ActionItem(R.drawable.ic_nighttime_26, context.getString(R.string.arrays__mute_for_eight_hours)) {
action(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8))
},
ActionItem(R.drawable.symbol_calendar_one, context.getString(R.string.arrays__mute_for_one_day)) {
action(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1))
},
ActionItem(R.drawable.symbol_calendar_week, context.getString(R.string.arrays__mute_for_seven_days)) {
action(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7))
},
ActionItem(R.drawable.symbol_calendar_24, context.getString(R.string.MuteDialog__mute_until)) {
MuteUntilTimePickerBottomSheet.show(fragmentManager)
},
ActionItem(R.drawable.symbol_bell_slash_24, context.getString(R.string.arrays__always)) {
action(Long.MAX_VALUE)
}
)
return SignalContextMenu.Builder(anchor, container)
.offsetX(12.dp)
.offsetY(12.dp)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
.show(actionItems)
}
}

View File

@@ -0,0 +1,272 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.text.format.DateFormat
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.DateValidatorPointForward
import com.google.android.material.datepicker.MaterialDatePicker
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.atMidnight
import org.thoughtcrime.securesms.util.atUTC
import org.thoughtcrime.securesms.util.formatHours
import org.thoughtcrime.securesms.util.toLocalDateTime
import org.thoughtcrime.securesms.util.toMillis
import java.time.DayOfWeek
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAdjusters
import java.util.Locale
class MuteUntilTimePickerBottomSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.66f
companion object {
const val REQUEST_KEY = "mute_until_result"
const val RESULT_TIMESTAMP = "timestamp"
@JvmStatic
fun show(fragmentManager: FragmentManager) {
val fragment = MuteUntilTimePickerBottomSheet()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
@Composable
override fun SheetContent() {
val context = LocalContext.current
val now = remember { LocalDateTime.now() }
val defaultDateTime = remember {
if (now.hour < 17) {
now.withHour(17).withMinute(0).withSecond(0).withNano(0)
} else {
val nextMorning = if (now.dayOfWeek == DayOfWeek.FRIDAY || now.dayOfWeek == DayOfWeek.SATURDAY || now.dayOfWeek == DayOfWeek.SUNDAY) {
now.with(TemporalAdjusters.next(DayOfWeek.MONDAY))
} else {
now.plusDays(1)
}
nextMorning.withHour(8).withMinute(0).withSecond(0).withNano(0)
}
}
var selectedDate by remember { mutableLongStateOf(defaultDateTime.toMillis()) }
var selectedHour by remember { mutableIntStateOf(defaultDateTime.hour) }
var selectedMinute by remember { mutableIntStateOf(defaultDateTime.minute) }
val dateText = remember(selectedDate) {
DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), selectedDate)
}
val timeText = remember(selectedHour, selectedMinute) {
LocalTime.of(selectedHour, selectedMinute).formatHours(context)
}
val zonedDateTime = remember { ZonedDateTime.now() }
val timezoneDisclaimer = remember {
val zoneOffsetFormatter = DateTimeFormatter.ofPattern("OOOO")
val zoneNameFormatter = DateTimeFormatter.ofPattern("zzzz")
context.getString(
R.string.MuteUntilTimePickerBottomSheet__timezone_disclaimer,
zoneOffsetFormatter.format(zonedDateTime),
zoneNameFormatter.format(zonedDateTime)
)
}
MuteUntilSheetContent(
dateText = dateText,
timeText = timeText,
timezoneDisclaimer = timezoneDisclaimer,
onDateClick = {
val local = LocalDateTime.now().atMidnight().atUTC().toMillis()
val datePicker = MaterialDatePicker.Builder.datePicker()
.setTitleText(context.getString(R.string.MuteUntilTimePickerBottomSheet__select_date_title))
.setSelection(selectedDate)
.setCalendarConstraints(CalendarConstraints.Builder().setStart(local).setValidator(DateValidatorPointForward.now()).build())
.build()
datePicker.addOnDismissListener {
datePicker.clearOnDismissListeners()
datePicker.clearOnPositiveButtonClickListeners()
}
datePicker.addOnPositiveButtonClickListener {
selectedDate = it.toLocalDateTime(ZoneOffset.UTC).atZone(ZoneId.systemDefault()).toMillis()
}
datePicker.show(childFragmentManager, "DATE_PICKER")
},
onTimeClick = {
val timeFormat = if (DateFormat.is24HourFormat(context)) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H
val timePicker = MaterialTimePicker.Builder()
.setTimeFormat(timeFormat)
.setHour(selectedHour)
.setMinute(selectedMinute)
.setTitleText(context.getString(R.string.MuteUntilTimePickerBottomSheet__select_time_title))
.build()
timePicker.addOnDismissListener {
timePicker.clearOnDismissListeners()
timePicker.clearOnPositiveButtonClickListeners()
}
timePicker.addOnPositiveButtonClickListener {
selectedHour = timePicker.hour
selectedMinute = timePicker.minute
}
timePicker.show(childFragmentManager, "TIME_PICKER")
},
onMuteClick = {
val timestamp = selectedDate.toLocalDateTime()
.withHour(selectedHour)
.withMinute(selectedMinute)
.withSecond(0)
.withNano(0)
.toMillis()
if (timestamp > System.currentTimeMillis()) {
setFragmentResult(REQUEST_KEY, bundleOf(RESULT_TIMESTAMP to timestamp))
dismissAllowingStateLoss()
}
}
)
}
}
@Composable
private fun MuteUntilSheetContent(
dateText: String,
timeText: String,
timezoneDisclaimer: String,
onDateClick: () -> Unit,
onTimeClick: () -> Unit,
onMuteClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
BottomSheets.Handle()
Text(
text = stringResource(R.string.MuteUntilTimePickerBottomSheet__dialog_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 18.dp, bottom = 24.dp)
)
Text(
text = timezoneDisclaimer,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier
.padding(horizontal = 56.dp)
.align(Alignment.Start)
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(onClick = onDateClick)
) {
Text(
text = dateText,
style = MaterialTheme.typography.bodyLarge
)
Icon(
painter = painterResource(R.drawable.ic_expand_down_24),
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp)
.size(24.dp)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(onClick = onTimeClick)
) {
Text(
text = timeText,
style = MaterialTheme.typography.bodyLarge
)
Icon(
painter = painterResource(R.drawable.ic_expand_down_24),
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp)
.size(24.dp)
)
}
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(start = 18.dp, end = 18.dp, top = 14.dp, bottom = 24.dp)
) {
Buttons.MediumTonal(
onClick = onMuteClick
) {
Text(stringResource(R.string.MuteUntilTimePickerBottomSheet__mute_notifications))
}
}
}
}
@DayNightPreviews
@Composable
private fun MuteUntilSheetContentPreview() {
Previews.BottomSheetContentPreview {
MuteUntilSheetContent(
dateText = "Today",
timeText = "5:00 PM",
timezoneDisclaimer = "All times in (GMT-05:00) Eastern Standard Time",
onDateClick = {},
onTimeClick = {},
onMuteClick = {}
)
}
}

View File

@@ -30,7 +30,7 @@ object ButtonStripPreference {
val onMessageClick: () -> Unit = {},
val onVideoClick: () -> Unit = {},
val onAudioClick: () -> Unit = {},
val onMuteClick: () -> Unit = {},
val onMuteClick: (View) -> Unit = {},
val onSearchClick: () -> Unit = {}
) : PreferenceModel<Model>() {
override fun areContentsTheSame(newItem: Model): Boolean {
@@ -97,7 +97,7 @@ object ButtonStripPreference {
message.setOnClickListener { model.onMessageClick() }
videoCall.setOnClickListener { model.onVideoClick() }
audioCall.setOnClickListener { model.onAudioClick() }
mute.setOnClickListener { model.onMuteClick() }
mute.setOnClickListener { model.onMuteClick(it) }
search.setOnClickListener { model.onSearchClick() }
addToStory.setOnClickListener { model.onAddToStoryClick() }
}

View File

@@ -16,9 +16,10 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
titleId = R.string.ConversationSettingsFragment__sounds_and_notifications
) {
class SoundsAndNotificationsSettingsFragment :
DSLSettingsFragment(
titleId = R.string.ConversationSettingsFragment__sounds_and_notifications
) {
private val mentionLabels: Array<String> by lazy {
resources.getStringArray(R.array.SoundsAndNotificationsSettingsFragment__mention_labels)
@@ -66,7 +67,7 @@ class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
summary = DSLSettingsText.from(muteSummary),
onClick = {
if (state.muteUntil <= 0) {
MuteDialog.show(requireContext(), viewModel::setMuteUntil)
MuteDialog.show(requireContext(), childFragmentManager, viewLifecycleOwner, viewModel::setMuteUntil)
} else {
MaterialAlertDialogBuilder(requireContext())
.setMessage(muteSummary)