diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt
index eb8c5e81e4..0efc8db555 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt
@@ -54,6 +54,19 @@ sealed interface ChatStep : FlowStep, Parcelable {
@Serializable
data object InviteToGroup : ChatStep, Sheet, WrapContentSheet
+ /**
+ * How long to mute the conversation for.
+ *
+ * A [WrapContentSheet] for the same reason [InviteToGroup] is one: four rows, chosen from the
+ * group's profile, and the profile is where you end up again once one is picked. Carries
+ * nothing — the chat is the one the flow is already open on, and the shape the user picks
+ * travels back as an event rather than as a nav result, because muting is a request the view
+ * model makes rather than an answer the sheet returns.
+ */
+ @Parcelize
+ @Serializable
+ data object MuteChat : ChatStep, Sheet, WrapContentSheet
+
@Parcelize
@Serializable
data class Profile(val contact: ChatParticipant): ChatStep
diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index b6d122b2c6..b52e9f77ed 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -905,6 +905,32 @@
You won’t receive new messages from this chat. You can join it again if it still lets you in
Something Went Wrong
We were unable to leave the chat. Please try again
+
+
+ Mute Notifications
+
+ You\'ll still receive messages and see unread counts. Only notifications are silenced
+ 1 Hour
+ 8 Hours
+ 1 Week
+ Always
+
+ Never
+
+ Muted until %1$s
+ Muted
+ Something Went Wrong
+ We were unable to mute the chat. Please try again
+ Something Went Wrong
+ We were unable to unmute the chat. Please try again
Couldn\'t Join
We were unable to join the chat. Please try again
Your balance doesn\'t meet this chat\'s requirement yet
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt
index 47af5c90da..046c1158cc 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt
@@ -22,11 +22,13 @@ import com.flipcash.app.messenger.internal.ChatSubject
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.screens.GroupInviteSheet
import com.flipcash.app.messenger.internal.screens.MessengerScreen
+import com.flipcash.app.messenger.internal.screens.MuteChatSheet
import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent
import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet
import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen
import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel
import com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen
+import com.flipcash.shared.chat.ui.rememberIsMuted
import com.getcode.navigation.annotatedEntry
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.navigation.flow.FlowHost
@@ -62,7 +64,8 @@ fun ChatFlowScreen(
// Popping with the IME still up drags the screen behind it out from under the keyboard.
onExit = { _, _ -> keyboard.hideIfVisible { navigator.pop() } },
entryProvider = chatEntryProvider(route.identifier, route.openKeyboard),
- // ChatStep.AmountEntry, ChatStep.InitPayment and ChatStep.InviteToGroup are Sheets, so the
+ // ChatStep.AmountEntry, ChatStep.InitPayment, ChatStep.InviteToGroup and ChatStep.MuteChat
+ // are Sheets, so the
// flow needs the sheet strategy to draw them as such; without it the step would fall
// through to SinglePane and cover the thread. Amount entry
// returns its result inside the flow (resultBackNavigator), so the strategy's own
@@ -92,6 +95,9 @@ private fun chatEntryProvider(
annotatedEntry {
FlowGroupInviteSheet()
}
+ annotatedEntry {
+ FlowMuteChatSheet()
+ }
annotatedEntry { step ->
FlowChatProfileScreen(step.contact)
}
@@ -240,6 +246,34 @@ private fun FlowGroupInviteSheet() {
)
}
+@Composable
+private fun FlowMuteChatSheet() {
+ val viewModel = flowSharedViewModel()
+ val state by viewModel.stateFlow.collectAsStateWithLifecycle()
+ // Same dismissal rule as the other sheets in this flow: exit through the sheet so it animates
+ // down rather than having its scene deleted mid-frame.
+ val dismissSheet = LocalBottomSheetDismissDispatcher.current
+
+ MuteChatSheet(
+ // Asked per composition rather than passed in as a boolean the caller computed: a timed
+ // mute lapses with nothing sent to say so, and the sheet's unmute row is what would
+ // otherwise be left standing over a chat that is already audible again.
+ isMuted = rememberIsMuted(state.viewerState),
+ // Dismissed on the tap rather than on the result: the request is fire-and-forget from here,
+ // and a failure is reported by the view model's own error bar, which draws over whatever is
+ // on screen by then.
+ onMute = {
+ viewModel.dispatchEvent(ChatViewModel.Event.MuteChat(it))
+ dismissSheet()
+ },
+ onUnmute = {
+ viewModel.dispatchEvent(ChatViewModel.Event.UnmuteChat)
+ dismissSheet()
+ },
+ onDismiss = dismissSheet,
+ )
+}
+
@Composable
private fun FlowChatProfileScreen(participant: ChatParticipant) {
val viewModel = flowSharedViewModel()
@@ -249,7 +283,7 @@ private fun FlowChatProfileScreen(participant: ChatParticipant) {
viewModel.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant))
}
- ChatProfileScreen(viewModel)
+ ChatProfileScreen(viewModel, flowSharedViewModel())
LaunchedEffect(viewModel) {
viewModel.eventFlow
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt
new file mode 100644
index 0000000000..e99ae070d7
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt
@@ -0,0 +1,116 @@
+package com.flipcash.app.messenger.internal
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.NotificationsOff
+import androidx.compose.material3.Icon
+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.alpha
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import com.flipcash.features.messenger.R
+import com.flipcash.services.models.chat.ViewerState
+import com.getcode.theme.CodeTheme
+
+/**
+ * What the chat's mute currently is, drawn under the title on both chat settings surfaces —
+ * "Muted until 5:56 PM" for a timed mute, plain "Muted" for an indefinite one, and nothing at all
+ * while the chat is audible.
+ *
+ * It sits here rather than on the mute row because the row has no space for it, and because a row
+ * whose own label reported the state read as a missed tap: the words under the finger changed to
+ * the opposite of what was just tapped. The row now says the same thing either way and this says
+ * what happened.
+ *
+ * Stating the deadline is what makes a timed mute trustworthy — without it a timed mute cannot be
+ * told apart from an indefinite one, which is the whole reason the picker offers both.
+ *
+ * Drawn as a chip rather than as another line of secondary text: the lines above it are the chat's
+ * own identity — its name, its size, when someone joined — and this is the viewer's setting. Given
+ * their styling it read as a fourth fact about the chat. Amber, because a mute is the one thing on
+ * the screen that stops being true on its own, and the colour is what separates it from the settled
+ * facts above; tinted rather than solid so it stays under the title. The ground is the amber itself
+ * at a tenth strength rather than a theme token, because what it has to sit on is whatever the host
+ * screen's background is.
+ *
+ * Takes the [ViewerState] rather than a label so the countdown behind it is this composable's —
+ * see [rememberMutedLabel], which drops the text the moment a timed mute lapses with nothing sent
+ * from the server to say so.
+ */
+@Composable
+internal fun ChatMuteStatusChip(
+ viewerState: ViewerState?,
+ modifier: Modifier = Modifier,
+) {
+ val label = rememberMutedLabel(viewerState)
+
+ Box(modifier = modifier, contentAlignment = Alignment.Center) {
+ // Holds the line's height whether or not there is a mute, so the rows below don't move the
+ // moment one lapses or is cleared — that reads as the screen re-laying itself out rather
+ // than one fact going away. Never drawn, never read aloud.
+ Chip(
+ label = stringResource(R.string.label_muted),
+ modifier = Modifier
+ .alpha(0f)
+ .clearAndSetSemantics { },
+ )
+
+ // Springs in and fades out. Arriving is the event worth seeing — the user just chose it —
+ // so it gets the overshoot; going away is either their unmute or a deadline passing, and
+ // neither wants attention drawn to it.
+ AnimatedVisibility(
+ visible = label != null,
+ enter = fadeIn() + scaleIn(initialScale = 0.85f, animationSpec = spring()),
+ exit = fadeOut(animationSpec = tween(durationMillis = 200)),
+ ) {
+ Chip(label = label.orEmpty())
+ }
+ }
+}
+
+@Composable
+private fun Chip(
+ label: String,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .background(
+ color = CodeTheme.colors.warning.copy(alpha = 0.1f),
+ shape = CircleShape,
+ )
+ .padding(
+ horizontal = CodeTheme.dimens.staticGrid.x2,
+ vertical = CodeTheme.dimens.staticGrid.x1,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1),
+ ) {
+ Icon(
+ modifier = Modifier.size(CodeTheme.dimens.staticGrid.x3),
+ imageVector = Icons.Outlined.NotificationsOff,
+ contentDescription = null,
+ tint = CodeTheme.colors.warning,
+ )
+ Text(
+ text = label,
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.warning,
+ )
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
index dc043ef159..86a2214fc1 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt
@@ -59,7 +59,9 @@ import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.models.chat.DeliveryStatus
import com.flipcash.services.models.chat.MessageContent
+import com.flipcash.services.models.chat.MuteState
import com.flipcash.services.models.chat.TypingState
+import com.flipcash.services.models.chat.ViewerState
import com.flipcash.services.models.chat.isDmAddressable
import com.flipcash.services.user.UserManager
import com.flipcash.shared.amountentry.AmountEntryDelegate
@@ -167,6 +169,16 @@ internal class ChatViewModel @Inject constructor(
val separatorConfig: SeparatorConfig = SeparatorConfig.Continuous(),
val chatId: ChatId? = null,
val subject: ChatSubject? = null,
+ /**
+ * What this chat holds about the viewer, or null while nothing is known.
+ *
+ * On the chat rather than on the [ChatSubject] because it is not about who the
+ * conversation is with: a DM is muted exactly the way a group is, and both profiles offer
+ * the row. Carried whole rather than reduced to a muted flag — a timed mute lapses with
+ * nothing sent to say so, so what is muted depends on when it is asked, and `isMutedAt`
+ * needs the deadline this keeps.
+ */
+ val viewerState: ViewerState? = null,
// The kind of DM this conversation is, resolved from the fast local contact lookup ahead of
// the participant's server profile (which resolves over the network for tip DMs). Starts
// UNKNOWN and settles to CONTACT_DM / TIP_DM as soon as the chat opens; the send button and
@@ -373,6 +385,21 @@ internal class ChatViewModel @Inject constructor(
/** The leave went through, so whatever is showing the group's profile should close. */
data object LeftChat : Event
+
+ /** This chat's viewer state moved, from the stream or from the viewer's own request. */
+ data class OnViewerStateResolved(val viewerState: ViewerState?) : Event
+
+ /**
+ * A shape picked from the mute sheet. Carries the whole [MuteState] rather than a duration
+ * because a deadline and forever are not the same kind of thing, and the sheet is the last
+ * place that can tell them apart.
+ *
+ * No confirmation, unlike the leave: muting is undone by the row that replaces it.
+ */
+ data class MuteChat(val mute: MuteState) : Event
+
+ /** The group profile's "Unmute Chat" row. Its own request, not a mute of zero length. */
+ data object UnmuteChat : Event
data class OnCurrencySymbolUpdated(val symbol: String): Event
data class OnChatInitFeeUpdated(val formatted: String?) : Event
data object RefreshContact : Event
@@ -1098,6 +1125,18 @@ internal class ChatViewModel @Inject constructor(
.onEach { dispatchEvent(Event.OnGroupResolved(it)) }
.launchIn(viewModelScope)
+ // The same metadata, minus the type filter: every chat has viewer state and every chat can
+ // be muted, so this one is not the group chrome's business. Observed rather than read with
+ // the chat because a mute made on another device arrives on the stream, and because leaving
+ // clears it server-side.
+ stateFlow.mapNotNull { it.chatId }
+ .distinctUntilChanged()
+ .flatMapLatest { chatCoordinator.observeMetadata(it) }
+ .map { it?.metadata?.viewerState }
+ .distinctUntilChanged()
+ .onEach { dispatchEvent(Event.OnViewerStateResolved(it)) }
+ .launchIn(viewModelScope)
+
// The cache starts empty and fills in, so this is observed rather than read once — a
// requirement resolved against an empty cache would render its amount with no token beside
// it and never correct itself.
@@ -1472,6 +1511,39 @@ internal class ChatViewModel @Inject constructor(
}
.launchIn(viewModelScope)
+ eventFlow.filterIsInstance()
+ .onEach { event ->
+ val chatId = stateFlow.value.chatId ?: return@onEach
+ // Nothing to dispatch on success: the stored viewer state is what the row reads,
+ // and `mute` writes it before returning, so the profile flips on the same flow the
+ // stream would have moved it on.
+ chatCoordinator.mute(chatId, event.mute)
+ .onFailure {
+ trace("failed to mute chat - ${it.localizedMessage}")
+ BottomBarManager.showError(
+ title = resources.getString(R.string.error_title_failedToMute),
+ message = resources.getString(R.string.error_description_failedToMute),
+ )
+ }
+ }
+ .launchIn(viewModelScope)
+
+ eventFlow.filterIsInstance()
+ .onEach {
+ val chatId = stateFlow.value.chatId ?: return@onEach
+ chatCoordinator.unmute(chatId)
+ .onFailure {
+ trace("failed to unmute chat - ${it.localizedMessage}")
+ BottomBarManager.showError(
+ title = resources.getString(R.string.error_title_failedToUnmute),
+ message = resources.getString(
+ R.string.error_description_failedToUnmute,
+ ),
+ )
+ }
+ }
+ .launchIn(viewModelScope)
+
// A bubble is what the UI has; a citation is what the composer needs, and building one
// reads the stored message. A message this device never stored drops the request rather
// than opening a strip with nothing in it.
@@ -1906,10 +1978,15 @@ internal class ChatViewModel @Inject constructor(
)
)
}
+ is Event.OnViewerStateResolved -> { state ->
+ state.copy(viewerState = event.viewerState)
+ }
Event.CopyInviteLink,
Event.LeaveChat,
Event.LeaveConfirmed,
- Event.LeftChat -> { state -> state }
+ Event.LeftChat,
+ is Event.MuteChat,
+ Event.UnmuteChat -> { state -> state }
is Event.OnTipUserResolved -> { state ->
// A device contact, once matched, wins over the server profile (it carries the
// phone number and the user's own naming). Otherwise this is a tip DM: adopt the
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteOption.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteOption.kt
new file mode 100644
index 0000000000..513d9e021a
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteOption.kt
@@ -0,0 +1,95 @@
+package com.flipcash.app.messenger.internal
+
+import android.text.format.DateFormat
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import com.flipcash.features.messenger.R
+import com.flipcash.services.models.chat.MuteState
+import com.flipcash.services.models.chat.ViewerState
+import com.flipcash.shared.chat.ui.rememberIsMuted
+import com.getcode.util.formatLocalized
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Clock
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.days
+import kotlin.time.Instant
+
+/**
+ * What the mute picker offers.
+ *
+ * The contract admits exactly two mute shapes — until a deadline, or forever — so the picker is a
+ * few deadlines and the indefinite one, and nothing else. There is no duration that means unmute:
+ * clearing a mute is its own call, reached from the profile's own row rather than from here.
+ */
+internal enum class MuteOption(
+ /** How long from now the mute runs, or `null` for the indefinite option. */
+ val duration: Duration?,
+ val labelRes: Int,
+) {
+ OneHour(1.hours, R.string.action_muteFor_oneHour),
+ EightHours(8.hours, R.string.action_muteFor_eightHours),
+ OneWeek(7.days, R.string.action_muteFor_oneWeek),
+ Forever(null, R.string.action_muteFor_forever);
+
+ /**
+ * The mute this option asks for, measured from [now].
+ *
+ * The deadline is minted here rather than server-side because the RPC takes one: what the user
+ * picked is a length, and only the caller knows when they picked it.
+ */
+ fun toMuteState(now: Instant = Clock.System.now()): MuteState =
+ duration?.let { MuteState.Until(now + it) } ?: MuteState.Forever
+}
+
+/**
+ * How much of a mute deadline has to be said for it to locate the moment.
+ *
+ * The same ladder [com.flipcash.app.messenger.internal.screens.components.DateSeparatorRow] climbs,
+ * pointed forwards: a time alone is unambiguous only within the day it falls in, and a weekday only
+ * within the week. Pure, and separate from the rendering, because which rung a deadline lands on is
+ * the part worth pinning — the patterns themselves are the platform's to localize.
+ */
+internal enum class MuteDeadlineFormat { TimeOnly, WeekdayAndTime, DateAndTime }
+
+/** Which rung [deadline] lands on, read from [today]. */
+internal fun muteDeadlineFormat(deadline: LocalDate, today: LocalDate): MuteDeadlineFormat {
+ val dayDiff = deadline.toEpochDays() - today.toEpochDays()
+ return when {
+ dayDiff <= 0L -> MuteDeadlineFormat.TimeOnly
+ dayDiff <= 6L -> MuteDeadlineFormat.WeekdayAndTime
+ else -> MuteDeadlineFormat.DateAndTime
+ }
+}
+
+/**
+ * What the muted row says on its right-hand side, or `null` when the chat is not muted right now.
+ *
+ * A timed mute states its deadline because otherwise it reads exactly like a permanent one, and the
+ * difference is the whole reason the picker offers both. Recomposes through [rememberIsMuted], so
+ * the readout disappears at the deadline rather than at the next screen open.
+ */
+@Composable
+internal fun rememberMutedLabel(viewerState: ViewerState?): String? {
+ if (!rememberIsMuted(viewerState)) return null
+ val until = (viewerState?.mute as? MuteState.Until)?.until
+ ?: return stringResource(R.string.label_muted)
+
+ val is24Hour = DateFormat.is24HourFormat(LocalContext.current)
+ val tz = TimeZone.currentSystemDefault()
+ val time = until.formatLocalized("h:mm a", is24Hour = is24Hour, if24Hour = "H:mm")
+ val deadline = when (
+ muteDeadlineFormat(
+ deadline = until.toLocalDateTime(tz).date,
+ today = Clock.System.now().toLocalDateTime(tz).date,
+ )
+ ) {
+ MuteDeadlineFormat.TimeOnly -> time
+ MuteDeadlineFormat.WeekdayAndTime -> "${until.formatLocalized("EEEE")} $time"
+ MuteDeadlineFormat.DateAndTime -> "${until.formatLocalized("MMM d")} $time"
+ }
+ return stringResource(R.string.label_mutedUntil, deadline)
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MuteChatSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MuteChatSheet.kt
new file mode 100644
index 0000000000..ffadef829a
--- /dev/null
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MuteChatSheet.kt
@@ -0,0 +1,122 @@
+package com.flipcash.app.messenger.internal.screens
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Notifications
+import androidx.compose.material.icons.outlined.NotificationsOff
+import androidx.compose.material.icons.outlined.Schedule
+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.vector.rememberVectorPainter
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import com.flipcash.app.messenger.internal.MuteOption
+import com.flipcash.features.messenger.R
+import com.flipcash.services.models.chat.MuteState
+import com.getcode.theme.CodeTheme
+import com.getcode.ui.components.AppBarDefaults
+import com.getcode.ui.components.AppBarWithTitle
+import com.getcode.ui.components.ChoiceRow
+
+/**
+ * How long to mute this conversation for.
+ *
+ * One row per [MuteOption], drawn as the chats flow's other choice screen draws its rows — a card
+ * per choice rather than a settings list, matching [GroupInviteSheet] and iOS' sheet of the same
+ * name.
+ *
+ * The contract admits exactly two mute shapes, a deadline or forever, so every duration row is one
+ * of the two. "Never" is the unmute: still its own request rather than a mute of zero length, and
+ * it reads as a duration only because every label here names the state it produces rather than the
+ * act that gets there.
+ *
+ * The deadline is minted on the tap rather than when the sheet opens, so a sheet left sitting still
+ * means what it says when it is finally used.
+ *
+ * @param isMuted whether the chat is muted *right now*, which is what decides the unmute row. Read
+ * live by the caller, so a mute that lapses under an open sheet drops the row with it.
+ */
+@Composable
+internal fun MuteChatSheet(
+ isMuted: Boolean,
+ onMute: (MuteState) -> Unit,
+ onUnmute: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ // Pinned on the way out, because the store converges before the sheet has finished animating
+ // away: read live, the list would gain or lose its first row while the user is still watching
+ // it. Null while the sheet is idle, so a lapsing mute still drops the row.
+ var pinnedOffersUnmute: Boolean? by remember { mutableStateOf(null) }
+ val offersUnmute = pinnedOffersUnmute ?: isMuted
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding(),
+ ) {
+ AppBarWithTitle(
+ title = stringResource(R.string.title_muteChat),
+ titleAlignment = Alignment.CenterHorizontally,
+ endContent = { AppBarDefaults.Close(onClick = onDismiss) },
+ )
+
+ // Worth saying because muting here does less than the word usually promises: the push still
+ // arrives and the message still lands in the thread, and only the notification is held back.
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = CodeTheme.dimens.inset),
+ text = stringResource(R.string.subtitle_muteChat),
+ style = CodeTheme.typography.textSmall,
+ color = CodeTheme.colors.textSecondary,
+ textAlign = TextAlign.Center,
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = CodeTheme.dimens.inset)
+ .padding(top = CodeTheme.dimens.grid.x4, bottom = CodeTheme.dimens.grid.x4),
+ verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
+ ) {
+ // Unmuting first: it is the one row that undoes what the others did, and it is only
+ // here at all while there is something to undo.
+ if (offersUnmute) {
+ ChoiceRow(
+ label = stringResource(R.string.action_muteFor_never),
+ icon = rememberVectorPainter(Icons.Outlined.Notifications),
+ onClick = {
+ pinnedOffersUnmute = offersUnmute
+ onUnmute()
+ },
+ )
+ }
+ for (option in MuteOption.entries) {
+ ChoiceRow(
+ label = stringResource(option.labelRes),
+ // A clock for the rows that name a length, and the bell-slash for the one that
+ // doesn't: nothing in the icon set says "8 hours", and what the timed rows have
+ // in common is that they end.
+ icon = rememberVectorPainter(
+ if (option.duration != null) Icons.Outlined.Schedule
+ else Icons.Outlined.NotificationsOff
+ ),
+ onClick = {
+ pinnedOffersUnmute = offersUnmute
+ onMute(option.toMuteState())
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt
index 23688fd983..07c061449a 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt
@@ -50,6 +50,7 @@ import com.flipcash.shared.chat.MessageCapability
import com.flipcash.shared.chat.models.ChatAction
import com.flipcash.shared.chat.models.ChatActionHandler
import com.flipcash.shared.chat.models.ChatListItem
+import com.flipcash.shared.chat.ui.MutedIndicator
import com.getcode.navigation.core.CodeNavigator
import com.getcode.theme.CodeTheme
import com.getcode.theme.extraLarge
@@ -167,14 +168,29 @@ private fun ConversationTitleBar(
)
Column(modifier = Modifier.weight(1f)) {
- Text(
- // Name-or-handle for a DM, the group's title for a group. A DM bar stays
- // one line (node 9443:9094): the handle is the only identity a name-less
- // tip counterparty has, so it is the title there rather than a second row.
- text = state.subject?.title.orEmpty(),
- style = CodeTheme.typography.textMedium,
- color = CodeTheme.colors.textMain,
- )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1),
+ ) {
+ Text(
+ // Name-or-handle for a DM, the group's title for a group. A DM bar
+ // stays one line (node 9443:9094): the handle is the only identity a
+ // name-less tip counterparty has, so it is the title there rather than
+ // a second row.
+ //
+ // Gives up width to the indicator rather than taking the whole line,
+ // so a long name loses its tail instead of pushing the bell off the bar.
+ modifier = Modifier.weight(1f, fill = false),
+ text = state.subject?.title.orEmpty(),
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textMain,
+ )
+
+ // Beside the name, not at the bar's edge: what is muted is this chat, and
+ // the name is what says which chat. An audible one emits nothing, so the
+ // spacing above is not spent either.
+ MutedIndicator(viewerState = state.viewerState)
+ }
// A group always shows its size, whether or not the viewer is in it — the
// count is what a join changes, and node 10125:19153 -> 10125:19201 is the
// same bar at two values of it. A DM has nothing to count.
diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt
index 1ee7127da7..fc93d31e67 100644
--- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt
+++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt
@@ -21,10 +21,15 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.core.chat.ChatParticipant
import com.flipcash.app.core.chat.ChatStep
+import com.flipcash.app.menu.MenuItem
import com.flipcash.app.menu.MenuList
+import com.flipcash.app.messenger.internal.ChatMuteStatusChip
+import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.asSubject
import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar
import com.flipcash.features.messenger.R
+import com.flipcash.services.models.chat.ChatType
+import com.flipcash.services.models.chat.ViewerState
import com.getcode.navigation.flow.rememberFlowNavigator
import com.getcode.theme.CodeTheme
import com.getcode.ui.components.AppBarWithTitle
@@ -35,10 +40,28 @@ import com.getcode.view.LoadingSuccessState
import kotlin.time.Instant
+/**
+ * A DM counterparty's profile.
+ *
+ * Two view models rather than one, because the screen says two kinds of thing about two different
+ * subjects. [viewModel] holds the person — their profile, their join date, whether they are
+ * blocked — and is the screen's own. [chatViewModel] is the conversation's, shared with the
+ * transcript, and it is what muting goes through: a mute is held by the chat, not by the person,
+ * and the same mute is reachable from a group's profile where there is no person at all.
+ *
+ * The mute row is shown for a tip DM only. This route is also how a group member's profile opens,
+ * and there the chat behind it is the group — a mute row on a member's profile would silence the
+ * whole group from a screen that names one person. Contact DMs never reach this screen
+ * ([com.flipcash.app.messenger.internal.ChatSubject.Contact] answers `canViewProfile` false).
+ */
@Composable
-internal fun ChatProfileScreen(viewModel: ChatProfileViewModel) {
+internal fun ChatProfileScreen(
+ viewModel: ChatProfileViewModel,
+ chatViewModel: ChatViewModel,
+) {
val flowNavigator = rememberFlowNavigator()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
+ val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle()
CodeScaffold(
topBar = {
@@ -49,19 +72,43 @@ internal fun ChatProfileScreen(viewModel: ChatProfileViewModel) {
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
- items = state.menuItems,
+ // Mute first, block last: the one that is reversible and routine sits above the one
+ // that ends the conversation, the same order the group's profile puts leaving in.
+ items = buildList