Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,32 @@
<string name="prompt_description_leaveChat">You won’t receive new messages from this chat. You can join it again if it still lets you in</string>
<string name="error_title_failedToLeave">Something Went Wrong</string>
<string name="error_description_failedToLeave">We were unable to leave the chat. Please try again</string>

<!-- Muting a chat. The contract has two mute shapes, a deadline and forever, so the sheet
offers durations plus an indefinite option; unmuting is its own request, which is why
"Never" is a row of its own rather than a fifth duration. Wording and the duration set
match iOS, which took them from WhatsApp\'s sheet plus Telegram\'s one-hour option. The
options name the state they produce ("1 Week") rather than the act ("Mute for 1 week"),
which is also what lets "Never" sit among them. -->
<string name="title_muteChat">Mute Notifications</string>
<!-- Load-bearing rather than decorative: a muted chat still delivers the push, still stores the
message and still counts it unread. Only the notification is held back. -->
<string name="subtitle_muteChat">You\'ll still receive messages and see unread counts. Only notifications are silenced</string>
<string name="action_muteFor_oneHour">1 Hour</string>
<string name="action_muteFor_eightHours">8 Hours</string>
<string name="action_muteFor_oneWeek">1 Week</string>
<string name="action_muteFor_forever">Always</string>
<!-- The unmute, offered only while there is a mute to clear. -->
<string name="action_muteFor_never">Never</string>
<!-- The mute status chip under a chat profile\'s title. %1$s is the deadline: a time today, a
weekday and time this week, a date and time beyond that. Without it a timed mute reads the
same as a permanent one. -->
<string name="label_mutedUntil">Muted until %1$s</string>
<string name="label_muted">Muted</string>
<string name="error_title_failedToMute">Something Went Wrong</string>
<string name="error_description_failedToMute">We were unable to mute the chat. Please try again</string>
<string name="error_title_failedToUnmute">Something Went Wrong</string>
<string name="error_description_failedToUnmute">We were unable to unmute the chat. Please try again</string>
<string name="error_title_failedToJoin">Couldn\'t Join</string>
<string name="error_description_failedToJoin">We were unable to join the chat. Please try again</string>
<string name="error_description_joinChat_rulesNotSatisfied">Your balance doesn\'t meet this chat\'s requirement yet</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -92,6 +95,9 @@ private fun chatEntryProvider(
annotatedEntry<ChatStep.InviteToGroup> {
FlowGroupInviteSheet()
}
annotatedEntry<ChatStep.MuteChat> {
FlowMuteChatSheet()
}
annotatedEntry<ChatStep.Profile> { step ->
FlowChatProfileScreen(step.contact)
}
Expand Down Expand Up @@ -240,6 +246,34 @@ private fun FlowGroupInviteSheet() {
)
}

@Composable
private fun FlowMuteChatSheet() {
val viewModel = flowSharedViewModel<ChatViewModel>()
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<ChatProfileViewModel>()
Expand All @@ -249,7 +283,7 @@ private fun FlowChatProfileScreen(participant: ChatParticipant) {
viewModel.dispatchEvent(ChatProfileViewModel.Event.OnParticipantSet(participant))
}

ChatProfileScreen(viewModel)
ChatProfileScreen(viewModel, flowSharedViewModel<ChatViewModel>())

LaunchedEffect(viewModel) {
viewModel.eventFlow
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1472,6 +1511,39 @@ internal class ChatViewModel @Inject constructor(
}
.launchIn(viewModelScope)

eventFlow.filterIsInstance<Event.MuteChat>()
.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<Event.UnmuteChat>()
.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.
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading