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> { + if (chatState.chatType == ChatType.TIP_DM) { + add(MuteDm) + } + add(BlockUser) + }, header = { ProfileHeader( participant = state.participant, joinDate = state.joinDate, + viewerState = chatState.viewerState, modifier = Modifier .fillMaxWidth() - .padding(top = CodeTheme.dimens.grid.x7), + // The gap under the header is the header's own, because MenuList puts + // nothing between its header slot and the first row. 40dp matches what + // iOS spends here (its 16pt stack spacing plus the row block's 24pt top + // inset); without it the mute chip sits against the first row and the + // screen reads as one block rather than a title above a list. + .padding( + top = CodeTheme.dimens.grid.x7, + bottom = CodeTheme.dimens.grid.x8, + ), ) }, - onItemClick = { viewModel.dispatchEvent(it.action) }, + onItemClick = { item -> + when (item.action) { + ChatProfileAction.Block -> + viewModel.dispatchEvent(ChatProfileViewModel.Event.BlockUser) + // Both muting and unmuting go through the picker, which is why this row + // navigates either way rather than acting on one of them here. + ChatProfileAction.Mute -> flowNavigator.navigateTo(ChatStep.MuteChat) + } + }, endSlot = { item -> - val loading = item.action == ChatProfileViewModel.Event.BlockUser && + val loading = item.action == ChatProfileAction.Block && state.processingState.state == LoadingSuccessState.State.Loading if (loading) { CodeCircularProgressIndicator( @@ -87,6 +134,7 @@ internal fun ProfileHeader( participant: ChatParticipant?, joinDate: Instant?, modifier: Modifier = Modifier, + viewerState: ViewerState? = null, ) { Column( modifier = modifier, @@ -99,7 +147,9 @@ internal fun ProfileHeader( .clip(CircleShape), ) Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + // Wider than the 5dp that binds the identity lines below it, so the name reads as the + // start of that block rather than as another line of the picture. + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), text = participant?.name.orEmpty(), style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, @@ -130,5 +180,11 @@ internal fun ProfileHeader( color = CodeTheme.colors.textSecondary, ) } + // Last, because it is the only line here that is the viewer's setting rather than a fact + // about the person, and the only one that can stop being true while the screen is open. + ChatMuteStatusChip( + viewerState = viewerState, + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt index 67bcc60626..285cdb1b53 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -5,7 +5,6 @@ import com.flipcash.app.blocklist.BlocklistCoordinator import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.featureflags.FeatureFlagController -import com.flipcash.app.menu.MenuItem import com.flipcash.features.messenger.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.controllers.ProfileController @@ -27,10 +26,6 @@ import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant -private val ProfileMenuItems = buildList { - add(BlockUser) -} - @HiltViewModel internal class ChatProfileViewModel @Inject constructor( private val contactCoordinator: ContactCoordinator, @@ -48,7 +43,6 @@ internal class ChatProfileViewModel @Inject constructor( data class State( val participant: ChatParticipant? = null, val joinDate: Instant? = null, - val menuItems: List> = ProfileMenuItems, val processingState: LoadingSuccessState = LoadingSuccessState(), ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt index 64059feb84..c0c0e2eb53 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt @@ -14,12 +14,13 @@ import com.flipcash.features.messenger.R * What the group's profile can act on. * * Its own type rather than [com.flipcash.app.messenger.internal.ChatViewModel.Event], because the - * two rows do different kinds of thing: leaving is a request to the conversation's view model, - * inviting is a navigation. Naming both here lets one [com.flipcash.app.menu.MenuList] carry them - * and the screen decide which is which. + * rows do different kinds of thing: leaving is a request to the conversation's view model, inviting + * and muting are navigations. Naming them here lets one + * [com.flipcash.app.menu.MenuList] carry them and the screen decide which is which. */ internal sealed interface GroupProfileAction { data object Invite : GroupProfileAction + data object Mute : GroupProfileAction data object Leave : GroupProfileAction } @@ -52,3 +53,6 @@ internal data object LeaveChat : FullMenuItem() { override val action: GroupProfileAction = GroupProfileAction.Leave } + +/** The group's mute row. Shared definition; see [MuteChatItem]. */ +internal val MuteChat = MuteChatItem(GroupProfileAction.Mute) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index f4600f025d..d42bd5ebd3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -21,10 +21,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ViewerState import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle @@ -65,19 +67,32 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { modifier = Modifier .fillMaxSize() .padding(innerPadding), - // Both rows are for members only: a non-member has no link to hand out and nothing to - // leave. Membership comes from the roster rather than from the gate, so the leave goes - // away the moment the leave itself lands, not when the balance rule next re-decides. + // Every row is for members only: a non-member has no link to hand out, nothing to + // leave, and no viewer state on a chat they are not in. Membership comes from the + // roster rather than from the gate, so the rows go away the moment the leave itself + // lands, not when the balance rule next re-decides. items = buildList> { if (state.groupInviteUrl != null) add(InviteToGroup) - if (group?.isMember == true) add(LeaveChat) + if (group?.isMember == true) { + add(MuteChat) + add(LeaveChat) + } }, header = { GroupProfileHeader( group = group, + viewerState = state.viewerState, modifier = Modifier .fillMaxWidth() - .padding(top = CodeTheme.dimens.grid.x7), + // The gap under the header is the header's own, because MenuList puts + // nothing between its header slot and the first row. 40dp matches what + // iOS spends here (its 16pt stack spacing plus the row block's 24pt top + // inset); without it the mute chip sits against the first row and the + // screen reads as one block rather than a title above a list. + .padding( + top = CodeTheme.dimens.grid.x7, + bottom = CodeTheme.dimens.grid.x8, + ), ) }, onItemClick = { item -> @@ -85,6 +100,9 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // The same sheet the transcript's own invite CTA opens, pushed on this flow so // it sits over the profile the user asked from. GroupProfileAction.Invite -> flowNavigator.navigateTo(ChatStep.InviteToGroup) + // Both muting and unmuting go through the picker, which is why this row + // navigates either way rather than acting on one of them here. + GroupProfileAction.Mute -> flowNavigator.navigateTo(ChatStep.MuteChat) GroupProfileAction.Leave -> viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat) } @@ -98,6 +116,7 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { internal fun GroupProfileHeader( group: ChatSubject.Group?, modifier: Modifier = Modifier, + viewerState: ViewerState? = null, ) { Column( modifier = modifier, @@ -110,7 +129,9 @@ internal fun GroupProfileHeader( .clip(CircleShape), ) Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + // Wider than the 5dp that binds the identity lines below it, so the name reads as the + // start of that block rather than as another line of the picture. + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), text = group?.title.orEmpty(), style = CodeTheme.typography.textLarge, color = CodeTheme.colors.textMain, @@ -131,5 +152,11 @@ internal fun GroupProfileHeader( color = CodeTheme.colors.textSecondary, ) } + // Last, because it is the only line here that is the viewer's setting rather than a fact + // about the group, and the only one that can stop being true while the screen is open. + ChatMuteStatusChip( + viewerState = viewerState, + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MuteMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MuteMenuItems.kt new file mode 100644 index 0000000000..c2a453a624 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MuteMenuItems.kt @@ -0,0 +1,38 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import com.flipcash.app.menu.FullMenuItem +import com.flipcash.features.messenger.R + +/** + * The row that opens the duration picker. + * + * Generic over the action because the two profiles that offer it — a group's and a DM's — name + * their own actions, and a [com.flipcash.app.menu.MenuItem] carries one action type. Muting is the + * same thing on both, so it is one row definition rather than two that can drift apart; what + * differs is only what the screen does with the tap. + * + * One row, reading the same either way, whether or not the chat is currently muted: the picker it + * opens is where unmuting lives too. A row that became "Unmute Notifications" under the tap that + * muted the chat read as a missed tap — the words under the finger had turned into the opposite of + * what was just chosen — and it left the row with nowhere to say *until when*. What the mute + * currently is belongs to [com.flipcash.app.messenger.internal.ChatMuteStatusChip], under the + * profile's title. + * + * Declare instances as top-level `val`s, naming the action type explicitly. [FullMenuItem] mints an + * id per instance, so one built inside a composition would get a new one on every recomposition; + * and inference off a `data object` argument picks that object's own type rather than the sealed + * interface the menu is typed on. + */ +internal class MuteChatItem(override val action: T) : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.NotificationsOff) + + override val name: String + @Composable get() = stringResource(R.string.title_muteChat) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt index d0a021b3a3..569cac053d 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ProfileMenuItems.kt @@ -9,11 +9,29 @@ import androidx.compose.ui.res.stringResource import com.flipcash.app.menu.FullMenuItem import com.flipcash.features.messenger.R -internal data object BlockUser: FullMenuItem() { +/** + * What a DM counterparty's profile can act on. + * + * Its own type rather than [ChatProfileViewModel.Event] for the same reason [GroupProfileAction] + * is one: the rows do different kinds of thing and answer to different view models. Blocking is + * this screen's own business, while muting is the conversation's — the chat is what gets muted, + * not the person — so naming the actions here lets one [com.flipcash.app.menu.MenuList] carry both + * and the screen route each tap to whichever holds it. + */ +internal sealed interface ChatProfileAction { + data object Block : ChatProfileAction + data object Mute : ChatProfileAction +} + +internal data object BlockUser : FullMenuItem() { override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Outlined.Block) override val name: String @Composable get() = stringResource(R.string.title_block) - override val action: ChatProfileViewModel.Event = ChatProfileViewModel.Event.BlockUser -} \ No newline at end of file + + override val action: ChatProfileAction = ChatProfileAction.Block +} + +/** The DM's mute row. Shared definition; see [MuteChatItem]. */ +internal val MuteDm = MuteChatItem(ChatProfileAction.Mute) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteOptionTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteOptionTest.kt new file mode 100644 index 0000000000..87e9d99a3d --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteOptionTest.kt @@ -0,0 +1,104 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState +import com.flipcash.services.models.chat.isMutedAt +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant +import kotlinx.datetime.LocalDate + +/** + * The picker's two jobs: turn a length into the deadline the RPC takes, and leave the indefinite + * option alone. The deadline is the client's to mint, so getting it wrong here mutes for the wrong + * span with nothing on the wire to show for it. + */ +class MuteOptionTest { + + private val now = Instant.fromEpochMilliseconds(1_700_000_000_000) + + @Test + fun `each timed option deadlines its own length from now`() { + assertEquals(MuteState.Until(now + 1.hours), MuteOption.OneHour.toMuteState(now)) + assertEquals(MuteState.Until(now + 8.hours), MuteOption.EightHours.toMuteState(now)) + assertEquals(MuteState.Until(now + 7.days), MuteOption.OneWeek.toMuteState(now)) + } + + @Test + fun `the indefinite option carries no deadline at all`() { + assertEquals(MuteState.Forever, MuteOption.Forever.toMuteState(now)) + } + + @Test + fun `no option means unmute`() { + // The row that clears a mute is a separate request. If any option here read as unmuted the + // moment it was picked, the profile would offer Mute again on a chat it had just muted. + for (option in MuteOption.entries) { + val viewerState = ViewerState(mute = option.toMuteState(now), version = 1L) + assertTrue(viewerState.isMutedAt(now), "${option.name} should be muted at the deadline it was minted from") + } + } + + @Test + fun `a timed option has lapsed once its deadline is behind`() { + val viewerState = ViewerState(mute = MuteOption.OneHour.toMuteState(now), version = 1L) + + assertTrue(viewerState.isMutedAt(now + 59.minutes)) + assertFalse(viewerState.isMutedAt(now + 61.minutes)) + } + + @Test + fun `the indefinite option never lapses`() { + val viewerState = ViewerState(mute = MuteOption.Forever.toMuteState(now), version = 1L) + + assertTrue(viewerState.isMutedAt(now + (365 * 10).days)) + } + + @Test + fun `a deadline today needs only a time`() { + val today = LocalDate(2026, 9, 18) + + assertEquals(MuteDeadlineFormat.TimeOnly, muteDeadlineFormat(today, today)) + } + + @Test + fun `a deadline inside the week names its weekday`() { + val today = LocalDate(2026, 9, 18) + + assertEquals( + MuteDeadlineFormat.WeekdayAndTime, + muteDeadlineFormat(LocalDate(2026, 9, 19), today), + ) + // Six days out is the last one a weekday still locates: the seventh is the same weekday + // again, which would read as a deadline that has already gone. + assertEquals( + MuteDeadlineFormat.WeekdayAndTime, + muteDeadlineFormat(LocalDate(2026, 9, 24), today), + ) + } + + @Test + fun `a deadline a week out or more gets a date`() { + val today = LocalDate(2026, 9, 18) + + assertEquals( + MuteDeadlineFormat.DateAndTime, + muteDeadlineFormat(LocalDate(2026, 9, 25), today), + ) + } + + @Test + fun `a deadline already behind falls back to a time rather than a weekday`() { + // Reachable in the frame between a deadline passing and the row dropping the readout. + // A past date rendered as a weekday would read as a mute still to come. + assertEquals( + MuteDeadlineFormat.TimeOnly, + muteDeadlineFormat(LocalDate(2026, 9, 17), LocalDate(2026, 9, 18)), + ) + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt index 74ecfd4314..03bd7afd10 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/components/TipChatRow.kt @@ -1,15 +1,20 @@ package com.flipcash.app.tipping.internal.components +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.shape.CircleShape 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 com.flipcash.shared.chat.ui.ChatListRow import com.flipcash.shared.chat.ui.ChatRowSubtitle import com.flipcash.shared.chat.ui.ChatRowTrailing import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.MutedIndicator import com.flipcash.shared.chat.ui.SubtitleText import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.theme.CodeTheme @@ -51,13 +56,27 @@ internal fun TipChatRow( ) }, subtitle = { - ChatRowSubtitle( - isTyping = chat.isTyping, - preview = chat.lastMessagePreview, - fallback = { - SubtitleText("") + // The mute sits at the end of this line rather than the one above it, where the + // timestamp and unread badge already compete for the trailing corner. It reads as the + // bottom of a column of chat-level state, and matches where iOS draws it. + Row( + verticalAlignment = Alignment.CenterVertically, + // An audible chat emits no indicator at all, so the gap is not spent either and + // the preview keeps the full width. + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + Box(modifier = Modifier.weight(1f)) { + ChatRowSubtitle( + isTyping = chat.isTyping, + preview = chat.lastMessagePreview, + fallback = { + SubtitleText("") + } + ) } - ) + + MutedIndicator(viewerState = chat.viewerState) + } }, showDivider = showDivider, onClick = onClick, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index 1707d26c4d..963abcf995 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -13,7 +13,8 @@ import com.getcode.util.resources.ResourceHelper /** * Maps a [ChatSummary] to the presentation [ConversationReference] shared by every * conversation-style list (send contacts, tip DMs, …): counterparty identity, a - * formatted last-message preview, the last-activity timestamp, and unread count. + * formatted last-message preview, the last-activity timestamp, unread count, and the viewer's + * own state on the chat. * * The counterparty ([ConversationReference.displayName]/[ConversationReference.image]) * is taken from the chat member that isn't [selfId] — used directly by rows with no @@ -43,6 +44,7 @@ fun ChatSummary.toConversationReference( lastMessagePreview = formatPreview(selfId, tokensByMint, resources), lastActivity = metadata.lastActivity, unreadCount = unreadCount, + viewerState = metadata.viewerState, ) } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt index d7a916e11c..99304e1c92 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt @@ -4,6 +4,7 @@ import com.getcode.opencode.model.core.ID import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.nameOrHandle import kotlin.time.Instant @@ -34,6 +35,14 @@ data class ConversationReference( val lastActivity: Instant? = null, val unreadCount: Int = 0, val isTyping: Boolean = false, + /** + * The viewer's own state on this chat, which is where the row's mute comes from. + * + * The state itself rather than a muted flag: a timed mute lapses with nothing sent to say so, + * so a boolean resolved when the row was built would go stale in place. [MutedIndicator] holds + * the deadline and stops drawing at it. + */ + val viewerState: ViewerState? = null, ) { /** * What to call this row: the chat's [title] for a group, otherwise the counterparty's diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt new file mode 100644 index 0000000000..7841929c7e --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MutedIndicator.kt @@ -0,0 +1,71 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.flipcash.core.R +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState +import com.flipcash.services.models.chat.isMutedAt +import com.getcode.theme.CodeTheme +import kotlinx.coroutines.delay +import kotlin.time.Clock + +/** + * The bell drawn wherever a chat is named, while that chat is muted right now. + * + * Takes the viewer state rather than a muted boolean on purpose. A timed mute lapses with nothing + * sent to say so, so a caller that resolved the answer itself would hand this a value that is + * already wrong by the time the deadline passes; [rememberIsMuted] waits the deadline out and stops + * drawing on its own. Emits nothing at all when the chat is audible, so a row pays no layout for it. + * + * Sized and tinted to sit with the caption text it stands beside — the row's preview, the title + * bar's name — rather than to be noticed on its own. It states a setting, it does not ask for + * anything. Filled rather than outlined, matching iOS' `bell.slash.fill`. The mute row and the + * profile chip stay outlined, as iOS' do — those sit at body size beside a label that already + * names the setting, where this one has to carry it alone at caption size. + */ +@Composable +fun MutedIndicator( + viewerState: ViewerState?, + modifier: Modifier = Modifier, +) { + if (!rememberIsMuted(viewerState)) return + Icon( + // Caption scale, a touch under the 14sp subtitle it trails and well under the 16sp title + // bar name. A Material glyph fills its box where the SF Symbol iOS draws carries its own + // padding, so matching iOS by the box size alone lands a visibly heavier bell. + modifier = modifier.size(CodeTheme.dimens.staticGrid.x3), + imageVector = Icons.Filled.NotificationsOff, + // Reaches a screen reader through the merged label of whatever container holds it — + // Compose concatenates a child's description into its parent's rather than dropping it, + // so the row reads its name, its timestamp and then this. + contentDescription = stringResource(R.string.label_muted), + tint = CodeTheme.colors.textSecondary, + ) +} + +/** + * Whether [viewerState] is muted right now, re-answered the moment a timed mute lapses. + * + * A composable rather than a plain read because the lapse has no event behind it. Nothing arrives + * from the server when a deadline passes, and nothing writes the row, so a screen that read the + * state once would keep showing a chat as muted after it had gone audible. This waits out the + * remaining time and then answers false. + */ +@Composable +fun rememberIsMuted(viewerState: ViewerState?): Boolean { + val mute = viewerState?.mute + return produceState(initialValue = viewerState.isMutedAt(), mute) { + value = viewerState.isMutedAt() + val until = (mute as? MuteState.Until)?.until ?: return@produceState + val remaining = until - Clock.System.now() + if (remaining.isPositive()) delay(remaining) + value = false + }.value +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MutedIndicatorTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MutedIndicatorTest.kt new file mode 100644 index 0000000000..d653bf6728 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/MutedIndicatorTest.kt @@ -0,0 +1,119 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours + +/** + * The indicator's two questions: whether it draws, and whether a screen reader hears it. + * + * The second is the reason this is a Compose test rather than a unit test of [rememberIsMuted]. + * Both surfaces that draw the bell sit inside a merging semantics node — a clickable row, a + * tappable title — and what such a node does with a child's description is the platform's choice, + * not ours. Compose concatenates rather than discarding, which is why the bell carries its own + * description instead of the containers appending to theirs; if that ever stopped being true the + * indicator would go silent with nothing else failing. + */ +@RunWith(RobolectricTestRunner::class) +class MutedIndicatorTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val mutedLabel = "Muted" + + private fun setIndicator(viewerState: ViewerState?) { + composeTestRule.setContent { + DesignSystem { + MutedIndicator(viewerState = viewerState) + } + } + } + + // --------------------------------------------------------------- + // Whether it draws + // --------------------------------------------------------------- + + @Test + fun `drawn while muted forever`() { + setIndicator(ViewerState(mute = MuteState.Forever)) + composeTestRule.onNodeWithContentDescription(mutedLabel).assertIsDisplayed() + } + + @Test + fun `drawn while a timed mute is still running`() { + setIndicator(ViewerState(mute = MuteState.Until(Clock.System.now() + 1.hours))) + composeTestRule.onNodeWithContentDescription(mutedLabel).assertIsDisplayed() + } + + @Test + fun `not drawn once a timed mute has lapsed`() { + // The state the server last sent, read after its deadline. Nothing arrives to clear it, so + // this is what a chat that has gone audible on its own actually looks like in the store. + setIndicator(ViewerState(mute = MuteState.Until(Clock.System.now() - 1.hours))) + composeTestRule.onNodeWithContentDescription(mutedLabel).assertDoesNotExist() + } + + @Test + fun `not drawn when the chat is not muted`() { + setIndicator(ViewerState(mute = null)) + composeTestRule.onNodeWithContentDescription(mutedLabel).assertDoesNotExist() + } + + @Test + fun `not drawn when there is no viewer state`() { + setIndicator(null) + composeTestRule.onNodeWithContentDescription(mutedLabel).assertDoesNotExist() + } + + // --------------------------------------------------------------- + // Whether a screen reader hears it + // --------------------------------------------------------------- + + @Test + fun `description survives a merging container`() { + composeTestRule.setContent { + DesignSystem { + Row(modifier = Modifier.clickable {}) { + Text("Alice") + MutedIndicator(viewerState = ViewerState(mute = MuteState.Forever)) + } + } + } + + // The clickable is the merged node; finding the description on it is what says the bell + // reached the label the row is read out as, rather than only existing under it. + composeTestRule + .onNode(hasClickAction() and hasContentDescription(mutedLabel)) + .assertExists() + } + + @Test + fun `merging container says nothing about muting when the chat is audible`() { + composeTestRule.setContent { + DesignSystem { + Row(modifier = Modifier.clickable {}) { + Text("Alice") + MutedIndicator(viewerState = null) + } + } + } + + composeTestRule.onNodeWithContentDescription(mutedLabel).assertDoesNotExist() + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index d769191afc..ec3a5587ff 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -14,6 +14,7 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.TypingState @@ -274,6 +275,24 @@ interface MessagingOperations { /** Notifies the server of the user's typing state in [chatId]. */ suspend fun notifyTyping(chatId: ChatId, typingState: TypingState): Result + + /** + * Mutes [chatId] for this viewer, [mute] saying until when or indefinitely, and stores the + * viewer state the server answers with. + * + * Not optimistic, unlike the leave: the stored state is versioned, and the version that decides + * whether a later stream update wins is the server's to mint. Writing a guess at it would + * either lose to the echo of this very call or shut a genuinely newer update out. + */ + suspend fun mute(chatId: ChatId, mute: MuteState): Result + + /** + * Clears the mute on [chatId] for this viewer. + * + * Its own call rather than a zero-length [mute]: the contract has two mute shapes, a deadline + * and forever, and neither of them says "not muted". + */ + suspend fun unmute(chatId: ChatId): Result } /** diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 46f12a88b4..077dfc416b 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -360,11 +360,10 @@ class EventStreamDelegate @Inject constructor( ) } is MetadataUpdate.ViewerStateChanged -> { - // The receiving user's own chat state (currently just mute) changed. - // Persisting it needs a chat_metadata column/migration that belongs - // with the muting feature's storage work, not this contract sync, so - // the update is acknowledged here without a write. Wire this up to - // metadataDataSource once that column exists. + // Convergent, like the roster and reaction overlays below: applied by version + // rather than in arrival order, because the stream does not order deliveries + // and a mute and the unmute after it can land either way round. + metadataDataSource.updateViewerState(chatId, metaUpdate.viewerState) } } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt index 55e6c6e2e7..e1b409ca68 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt @@ -150,10 +150,16 @@ class GroupFeedDelegate @Inject constructor( * The row is cleared first so the chat drops out of the list on the tap rather than a round * trip later, and put back if the call fails — the same optimistic shape as * [FeedSyncDelegate.setChatHidden], with the restore that a remote call needs. + * + * The viewer state goes on success, not optimistically: leaving clears the mute server-side + * and the server sends nothing to say so, so a retained mute would outlive the chat it + * belonged to and be waiting on the other side of a rejoin. A failed leave leaves it alone, + * which is what the restore above wants — the user is still in the chat, still muted. */ override suspend fun leave(chatId: ChatId): Result { metadataDataSource.setMembership(chatId, isMember = false) return chatController.leaveChat(chatId) + .onSuccess { metadataDataSource.clearViewerState(chatId) } .onFailure { trace(tag = TAG, message = "Leave failed for $chatId", type = TraceType.Error) metadataDataSource.setMembership(chatId, isMember = true) @@ -188,6 +194,9 @@ class GroupFeedDelegate @Inject constructor( is RosterChange.MemberLeft -> { if (change.userId != selfId) continue metadataDataSource.setMembership(chatId, isMember = false) + // Same reason as [leave]: the mute is gone server-side the moment you are out + // of the chat, whether you left from this device or another one. + metadataDataSource.clearViewerState(chatId) } } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 6e3cffc0bd..7aea5900b4 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -21,8 +21,10 @@ import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.TypingState +import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.DeleteMessageError import com.flipcash.services.models.EditMessageError import com.flipcash.services.models.UserProfile @@ -395,6 +397,20 @@ class MessagingDelegate @Inject constructor( return messagingController.notifyIsTyping(chatId, typingState) } + override suspend fun mute(chatId: ChatId, mute: MuteState): Result = + chatController.muteChat(chatId, mute).storeViewerState(chatId) + + override suspend fun unmute(chatId: ChatId): Result = + chatController.unmuteChat(chatId).storeViewerState(chatId) + + /** + * Stores the viewer state a mute call answered with, through the same version gate the stream + * writes go through: the call's own echo can arrive on the stream first, and either order has + * to land on the same row. + */ + private suspend fun Result.storeViewerState(chatId: ChatId): Result = + onSuccess { metadataDataSource.updateViewerState(chatId, it) }.map { } + // endregion // region Internal diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ViewerStateStreamTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ViewerStateStreamTest.kt new file mode 100644 index 0000000000..a14cc7907d --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ViewerStateStreamTest.kt @@ -0,0 +1,215 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.app.persistence.sources.ContactDataSource +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.controllers.EventStreamingController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.MetadataUpdate +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.ChatIdGenerator +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.RealChatCoordinator +import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate +import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate +import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import com.getcode.utils.network.NetworkConnectivityListener +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +/** + * The viewer-state arm of the event stream: a `ViewerStateChanged` is stored rather than dropped. + * + * These assert the hand-off only. Which of two viewer states wins is decided by the version gate in + * the DAO, and is covered where that gate lives + * ([com.flipcash.app.persistence.dao.ChatMetadataDaoTest]) — the delegate hands every update over + * and lets the version sort them out, because it cannot know what else is in flight. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ViewerStateStreamTest { + + private val selfId = listOf(1, 2, 3) + private val chatId = ChatId("aabbccdd") + + private val chatUpdatesChannel = Channel(capacity = Channel.UNLIMITED) + + private lateinit var metadataDataSource: ChatMetadataDataSource + private lateinit var coordinator: RealChatCoordinator + private lateinit var testDispatchers: TestDispatchers + + @Before + fun setUp() { + val userManager = mockk(relaxed = true) + every { userManager.accountId } returns selfId + val eventStreamingController = mockk(relaxed = true) + every { eventStreamingController.chatUpdates } returns chatUpdatesChannel.receiveAsFlow() + every { eventStreamingController.isConnected } returns true + every { eventStreamingController.isStreamActive } returns true + + val chatController = mockk(relaxed = true) + coEvery { chatController.getDmChatFeed(any(), any()) } returns + Result.failure(RuntimeException("not needed")) + + testDispatchers = TestDispatchers(TestCoroutineScheduler()) + + val stateHolder = ChatStateHolder() + val memberDataSource = mockk(relaxed = true) + val messagingController = mockk(relaxed = true) + metadataDataSource = mockk(relaxed = true) + val messageDataSource = mockk(relaxed = true) + + val feedDelegate = FeedSyncDelegate( + chatController = chatController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + stateHolder = stateHolder, + userManager = userManager, + ) + + val eventStreamDelegate = EventStreamDelegate( + eventStreamingController = eventStreamingController, + messagingController = messagingController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + tokenCoordinator = mockk(relaxed = true), + userManager = userManager, + stateHolder = stateHolder, + analytics = mockk(relaxed = true), + exchange = mockk(relaxed = true), + ) + + val messagingDelegate = MessagingDelegate( + chatController = chatController, + messagingController = messagingController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + notificationManager = mockk(relaxed = true), + userManager = userManager, + stateHolder = stateHolder, + analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), + ) + + val dmChatResolverDelegate = DmChatResolverDelegate( + chatIdGenerator = ChatIdGenerator(), + userManager = userManager, + contactDataSource = mockk(relaxed = true), + memberDataSource = memberDataSource, + ) + + coordinator = RealChatCoordinator( + feedDelegate = feedDelegate, + eventStreamDelegate = eventStreamDelegate, + dmChatResolverDelegate = dmChatResolverDelegate, + messagingDelegate = messagingDelegate, + stateHolder = stateHolder, + userManager = userManager, + networkObserver = mockk(relaxed = true), + dispatchers = testDispatchers, + groupFeedDelegate = mockk(relaxed = true), + ) + } + + private suspend fun triggerCollection() { + coordinator.onUserLoggedIn(mockk(relaxed = true)) + } + + private fun viewerStateUpdate(viewerState: ViewerState) = ChatUpdate( + chatId = chatId, + metadataUpdates = listOf(MetadataUpdate.ViewerStateChanged(viewerState)), + ) + + @Test + fun `a timed mute from the stream is stored`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + val viewerState = ViewerState( + mute = MuteState.Until(Instant.fromEpochSeconds(2_000)), + version = 4, + ) + + chatUpdatesChannel.send(viewerStateUpdate(viewerState)) + advanceTimeBy(1_000.milliseconds) + runCurrent() + + coVerify(exactly = 1) { metadataDataSource.updateViewerState(chatId, viewerState) } + coordinator.teardown() + } + + @Test + fun `an indefinite mute from the stream is stored`() = runTest(testDispatchers.dispatcher) { + triggerCollection() + val viewerState = ViewerState(mute = MuteState.Forever, version = 7) + + chatUpdatesChannel.send(viewerStateUpdate(viewerState)) + advanceTimeBy(1_000.milliseconds) + runCurrent() + + coVerify(exactly = 1) { metadataDataSource.updateViewerState(chatId, viewerState) } + coordinator.teardown() + } + + /** + * An unmute arrives as a viewer state carrying no mute, not as an absent update: the version it + * carries is what makes it beat the mute it replaces, so it has to be written like any other. + */ + @Test + fun `an unmute from the stream is stored at its own version`() = + runTest(testDispatchers.dispatcher) { + triggerCollection() + val viewerState = ViewerState(mute = null, version = 8) + + chatUpdatesChannel.send(viewerStateUpdate(viewerState)) + advanceTimeBy(1_000.milliseconds) + runCurrent() + + coVerify(exactly = 1) { metadataDataSource.updateViewerState(chatId, viewerState) } + coordinator.teardown() + } + + @Test + fun `an update carrying no viewer state leaves the stored one alone`() = + runTest(testDispatchers.dispatcher) { + triggerCollection() + + chatUpdatesChannel.send( + ChatUpdate( + chatId = chatId, + metadataUpdates = listOf( + MetadataUpdate.LastActivityChanged(Instant.fromEpochSeconds(2_000)), + ), + ), + ) + advanceTimeBy(1_000.milliseconds) + runCurrent() + + coVerify(exactly = 0) { metadataDataSource.updateViewerState(any(), any()) } + coordinator.teardown() + } +} diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt index 180a5e6345..fac6fb9ffc 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt @@ -21,10 +21,24 @@ fun planPushHandling( ): List { val sync = syncActionsFor(payload) - // The sync half is the same either way; a title only adds the notification on top. - return if (title == null) sync else sync + PushAction.PostNotification(title, body, payload) + // The sync half is the same either way; a title adds the notification on top, unless the + // chat was silenced when the push was sent. + if (title == null || isMuted(payload)) return sync + return sync + PushAction.PostNotification(title, body, payload) } +/** + * Whether this push arrived for a chat the recipient has silenced. + * + * Read off the payload rather than off the local mute, because the flag is what the server decided + * when it sent: a timed mute lapses with nothing sent to say so, so the two can disagree, and the + * send is the moment that matters. The server delivers a muted push anyway, deliberately, so the + * message still reaches the database — which is why suppression lands here, on the one action that + * is user-visible, and not on the push as a whole. + */ +private fun isMuted(payload: NotificationPayload?): Boolean = + payload?.chatMetadata?.muted == true + /** * The sync work an event class implies, independent of where the push * navigates. diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt index c5387421e9..3973600c4f 100644 --- a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt +++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt @@ -35,10 +35,11 @@ class PushHandlingPlannerTest { eventSequence = eventSequence, ) - private fun chatMetadata(message: ChatMessage?) = PushChatMetadata( + private fun chatMetadata(message: ChatMessage?, muted: Boolean = false) = PushChatMetadata( sendingUserId = null, chatType = ChatType.CONTACT_DM, message = message, + muted = muted, ) // region A titled push @@ -199,6 +200,66 @@ class PushHandlingPlannerTest { // endregion + // region A muted chat + + @Test + fun `a muted chat push stores its message and posts nothing`() { + val chatId = ChatId("aa12") + val message = inlinedMessage() + val p = payload( + navigation = NavigationTrigger.Chat.ById(chatId), + chatMetadata = chatMetadata(message, muted = true), + ) + val actions = planPushHandling("Title", "Body", p) + assertEquals(listOf(PushAction.RefreshFeed, PushAction.ApplyMessage(chatId, message)), actions) + } + + @Test + fun `a muted chat push with no inlined message still fetches it`() { + val chatId = ChatId("aa13") + val p = payload( + navigation = NavigationTrigger.Chat.ById(chatId), + chatMetadata = chatMetadata(message = null, muted = true), + ) + val actions = planPushHandling("Title", "Body", p) + assertEquals(listOf(PushAction.RefreshFeed, PushAction.LoadMessages(chatId)), actions) + } + + @Test + fun `muting changes only the notification, not the sync plan`() { + val chatId = ChatId("aa14") + val message = inlinedMessage() + val audible = payload( + navigation = NavigationTrigger.Chat.ById(chatId), + chatMetadata = chatMetadata(message), + ) + val silenced = payload( + navigation = NavigationTrigger.Chat.ById(chatId), + chatMetadata = chatMetadata(message, muted = true), + ) + assertEquals( + planPushHandling("Title", "Body", audible).filterNot { it is PushAction.PostNotification }, + planPushHandling("Title", "Body", silenced), + ) + } + + @Test + fun `the same chat posts once it is no longer muted`() { + val p = payload( + navigation = NavigationTrigger.Chat.ById(ChatId("aa14")), + chatMetadata = chatMetadata(inlinedMessage(), muted = false), + ) + assertTrue(planPushHandling("Title", "Body", p).last() is PushAction.PostNotification) + } + + @Test + fun `a muted push that names no chat is silenced too`() { + val p = payload(chatMetadata = chatMetadata(message = null, muted = true)) + assertEquals(emptyList(), planPushHandling("Title", "Body", p)) + } + + // endregion + companion object { private val TEST_MINT = Mint.usdc } diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/35.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/35.json new file mode 100644 index 0000000000..c9b3f163f9 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/35.json @@ -0,0 +1,842 @@ +{ + "formatVersion": 1, + "database": { + "version": 35, + "identityHash": "996b76c50f037d1cd142131808e343d5", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '996b76c50f037d1cd142131808e343d5')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index 115bff2173..0197e15636 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -95,8 +95,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 31, to = 32, spec = FlipcashDatabase.Migration31To32::class), AutoMigration(from = 32, to = 33), // chat_messages index on (chat_id_hex, timestamp_epoch_ms) AutoMigration(from = 33, to = 34), // chat_metadata group columns: title, picture, roster, rules, membership + AutoMigration(from = 34, to = 35), // chat_metadata viewer state: mute deadline, forever flag, version ], - version = 34, + version = 35, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index b680193d06..c4ec537bf1 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -74,8 +74,9 @@ interface ChatMetadataDao { /** * Overwrites only the columns the server owns. `latest_event_sequence` and * `analytics_counted_through` are client-owned watermarks that no server payload - * carries, so they are deliberately absent here. The roster columns are absent too — - * they are versioned, and go through [updateRosterIfNewer]. + * carries, so they are deliberately absent here. The roster and viewer-state columns + * are absent too — they are versioned, and go through [updateRosterIfNewer] and + * [updateViewerStateIfNewer]. */ @Query( "UPDATE chat_metadata SET chat_type = :chatType, " + @@ -114,6 +115,47 @@ interface ChatMetadataDao { ) suspend fun updateRosterIfNewer(chatIdHex: String, memberCount: Long, rosterVersion: Long) + /** The viewer-state version already applied to [chatIdHex], or null when the chat is not stored. */ + @Query("SELECT viewer_state_version FROM chat_metadata WHERE chat_id_hex = :chatIdHex") + suspend fun getViewerStateVersion(chatIdHex: String): Long? + + /** + * Applies a viewer state only when it is strictly newer than the stored one. + * + * `ViewerState.version` is what makes the state converge: stream delivery is not ordered, so + * a mute and the unmute that followed it can arrive either way round, and applying the last + * one to land would leave the wrong answer stored. Guarding on the version is also what stops + * a [ChatMetadataEntity] rebuilt from this row — which reports the version it was read at — + * from re-applying itself on the way back through an upsert. + * + * Both mute columns are written together so a switch between the two shapes clears the other. + */ + @Query( + "UPDATE chat_metadata SET mute_until_epoch_ms = :muteUntilEpochMs, " + + "mute_forever = :muteForever, viewer_state_version = :version " + + "WHERE chat_id_hex = :chatIdHex AND :version > viewer_state_version" + ) + suspend fun updateViewerStateIfNewer( + chatIdHex: String, + muteUntilEpochMs: Long?, + muteForever: Boolean, + version: Long, + ) + + /** + * Drops the viewer state, version and all. + * + * Leaving a chat clears the mute server-side, and the server sends nothing to say so. The + * version goes back to zero with it: a rejoin starts the server's numbering over, so a + * retained version would make the gate reject the new state and the old mute would survive a + * chat the user has left and re-entered. + */ + @Query( + "UPDATE chat_metadata SET mute_until_epoch_ms = NULL, mute_forever = 0, " + + "viewer_state_version = 0 WHERE chat_id_hex = :chatIdHex" + ) + suspend fun clearViewerState(chatIdHex: String) + /** * Inserts a new chat, or refreshes an existing one's server-owned columns in place. * @@ -143,6 +185,12 @@ interface ChatMetadataDao { memberCount = entity.memberCount, rosterVersion = entity.rosterVersion, ) + updateViewerStateIfNewer( + chatIdHex = entity.chatIdHex, + muteUntilEpochMs = entity.muteUntilEpochMs, + muteForever = entity.muteForever, + version = entity.viewerStateVersion, + ) } @Transaction diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt index 940404a8b0..21639fa2e4 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt @@ -44,4 +44,15 @@ data class ChatMetadataEntity( // should be readable in the table rather than implied by whether a row exists. @ColumnInfo(name = "is_member", defaultValue = "1") val isMember: Boolean = true, + // The viewer's own mute, and the version that decides whether a viewer-state write applies. + // A timed mute is stored as its deadline rather than as a muted flag: it lapses with no + // server signal, so only the deadline compared against the clock on read stays correct. + // Exactly two mute shapes exist — until a deadline, or forever — so the two columns are + // never both set. + @ColumnInfo(name = "mute_until_epoch_ms") + val muteUntilEpochMs: Long? = null, + @ColumnInfo(name = "mute_forever", defaultValue = "0") + val muteForever: Boolean = false, + @ColumnInfo(name = "viewer_state_version", defaultValue = "0") + val viewerStateVersion: Long = 0, ) diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt index 120bdf7559..576451dee1 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt @@ -58,6 +58,9 @@ class ChatMetadataDaoTest { rosterVersion: Long = 0, rulesJson: ChatRulesSerialized? = null, isMember: Boolean = true, + muteUntilEpochMs: Long? = null, + muteForever: Boolean = false, + viewerStateVersion: Long = 0, ) = ChatMetadataEntity( chatIdHex = chatIdHex, chatType = chatType, @@ -72,6 +75,9 @@ class ChatMetadataDaoTest { rosterVersion = rosterVersion, rulesJson = rulesJson, isMember = isMember, + muteUntilEpochMs = muteUntilEpochMs, + muteForever = muteForever, + viewerStateVersion = viewerStateVersion, ) @Test @@ -186,6 +192,95 @@ class ChatMetadataDaoTest { assertEquals(4L, stored?.rosterVersion) } + @Test + fun `a newer viewer state version replaces the mute columns`() = runTest { + dao.upsert(entity(chatType = "GROUP", muteUntilEpochMs = 5_000, viewerStateVersion = 4)) + + dao.updateViewerStateIfNewer( + chatIdHex = CHAT_HEX, + muteUntilEpochMs = null, + muteForever = false, + version = 5, + ) + + val stored = requireNotNull(dao.getById(CHAT_HEX)) + assertNull(stored.muteUntilEpochMs) + assertEquals(false, stored.muteForever) + assertEquals(5L, stored.viewerStateVersion) + } + + /** + * The stream does not order deliveries, so a mute and the unmute that followed it can arrive + * either way round. Applying whichever landed last would leave the wrong answer stored, which + * is why the version and not the arrival decides. + */ + @Test + fun `an older viewer state version leaves the mute alone`() = runTest { + dao.upsert(entity(chatType = "GROUP", viewerStateVersion = 0)) + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, version = 5) + + // The mute this supersedes, arriving late. + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 5_000, muteForever = false, version = 4) + + val stored = requireNotNull(dao.getById(CHAT_HEX)) + assertEquals(true, stored.muteForever) + assertNull(stored.muteUntilEpochMs) + assertEquals(5L, stored.viewerStateVersion) + } + + /** + * The same gate an upsert goes through. A `ChatMetadata` rebuilt from this row reports the + * version it was read at, so pushing it back must be a no-op rather than a re-application. + */ + @Test + fun `an upsert at the stored version does not rewrite the mute`() = runTest { + dao.upsert(entity(chatType = "GROUP", muteForever = true, viewerStateVersion = 7)) + + dao.upsert(entity(chatType = "GROUP", muteUntilEpochMs = null, muteForever = false, viewerStateVersion = 7)) + + assertEquals(true, dao.getById(CHAT_HEX)?.muteForever) + } + + @Test + fun `switching to an indefinite mute clears the deadline`() = runTest { + dao.upsert(entity(chatType = "GROUP", muteUntilEpochMs = 5_000, viewerStateVersion = 1)) + + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = null, muteForever = true, version = 2) + + val stored = requireNotNull(dao.getById(CHAT_HEX)) + assertNull(stored.muteUntilEpochMs) + assertEquals(true, stored.muteForever) + } + + /** + * Leaving clears the mute server-side and says nothing about it, and a rejoin starts the + * server's versioning over — so a version left behind would make the gate reject the new + * state and the old mute would survive the chat it belonged to. + */ + @Test + fun `clearing the viewer state drops the mute and its version`() = runTest { + dao.upsert(entity(chatType = "GROUP", muteForever = true, viewerStateVersion = 7)) + + dao.clearViewerState(CHAT_HEX) + + val stored = requireNotNull(dao.getById(CHAT_HEX)) + assertEquals(false, stored.muteForever) + assertNull(stored.muteUntilEpochMs) + assertEquals(0L, stored.viewerStateVersion) + + // A rejoin's first state numbers from the bottom again, and must still apply. + dao.updateViewerStateIfNewer(CHAT_HEX, muteUntilEpochMs = 9_000, muteForever = false, version = 1) + assertEquals(9_000L, dao.getById(CHAT_HEX)?.muteUntilEpochMs) + } + + @Test + fun `the viewer state version reads back, and is null for a chat that is not there`() = runTest { + dao.upsert(entity(chatType = "GROUP", muteForever = true, viewerStateVersion = 7)) + + assertEquals(7L, dao.getViewerStateVersion(CHAT_HEX)) + assertEquals(null, dao.getViewerStateVersion(OTHER_HEX)) + } + /** * One ordering across every type is the whole point of the merged feed: a group that belongs * between two DMs has to come back between them, not after all of them. diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index ffe0ff3fcf..1c26bbb3bb 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -9,6 +9,7 @@ import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.ViewerState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map @@ -65,6 +66,29 @@ class ChatMetadataDataSource @Inject constructor( ) } + /** The viewer-state version already applied to [chatId]; 0 when the chat is not stored yet. */ + suspend fun getViewerStateVersion(chatId: ChatId): Long = + db?.chatMetadataDao()?.getViewerStateVersion(mapper.chatIdHex(chatId)) ?: 0L + + /** + * Stores [viewerState] if its version beats the stored one, and drops it otherwise. Stream + * delivery is unordered, so the newest state is the one with the highest version, not the one + * that arrived last. + */ + suspend fun updateViewerState(chatId: ChatId, viewerState: ViewerState) { + db?.chatMetadataDao()?.updateViewerStateIfNewer( + chatIdHex = mapper.chatIdHex(chatId), + muteUntilEpochMs = mapper.muteUntilEpochMs(viewerState.mute), + muteForever = mapper.isMuteForever(viewerState.mute), + version = viewerState.version, + ) + } + + /** Forgets the viewer state for [chatId], version included. Leaving a chat clears its mute. */ + suspend fun clearViewerState(chatId: ChatId) { + db?.chatMetadataDao()?.clearViewerState(mapper.chatIdHex(chatId)) + } + /** The hex a [chatId] is keyed by. Callers comparing against [getChatIdsOfType] need it. */ fun chatIdHex(chatId: ChatId): String = mapper.chatIdHex(chatId) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index a68f3587d1..f83d371038 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -31,10 +31,12 @@ import com.flipcash.services.models.chat.Emoji import com.flipcash.services.models.chat.EmojiReaction import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.Reactor import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.services.models.chat.ViewerState import com.flipcash.services.models.chat.ClientMessageId import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.CurrencyCode @@ -84,6 +86,9 @@ class ChatEntityMapper @Inject constructor() { rosterVersion = metadata.rosterSummary.version, rulesJson = metadata.rules?.toSerialized(), isMember = isMember, + muteUntilEpochMs = muteUntilEpochMs(metadata.viewerState?.mute), + muteForever = isMuteForever(metadata.viewerState?.mute), + viewerStateVersion = metadata.viewerState?.version ?: 0, ) } @@ -109,9 +114,40 @@ class ChatEntityMapper @Inject constructor() { version = entity.rosterVersion, ), rules = entity.rulesJson?.toDomain(), + // Carried at its real version, unlike `latestEventSequence` above: the version is the + // thing the write gate compares, so a metadata rebuilt from this row and pushed back + // through an upsert is a no-op rather than a re-application of what is already there. + viewerState = entity.toViewerState(), ) } + /** The `mute_until_epoch_ms` column a [mute] writes; null for forever and for no mute. */ + fun muteUntilEpochMs(mute: MuteState?): Long? = + (mute as? MuteState.Until)?.until?.toEpochMilliseconds() + + /** The `mute_forever` column a [mute] writes. */ + fun isMuteForever(mute: MuteState?): Boolean = mute is MuteState.Forever + + /** + * The row's viewer state, or null when it holds nothing about the viewer. + * + * A lapsed deadline still reads back as a [MuteState.Until]; whether it is still in force is + * `isActiveAt(now)`'s answer, asked at render time. Dropping it here would make the row and + * the domain model disagree about what is stored, and a later write gate compare against a + * version whose state had been silently rewritten. + */ + private fun ChatMetadataEntity.toViewerState(): ViewerState? { + // Read into a local first: a smart cast will not hold across a module boundary. + val until = muteUntilEpochMs + val mute = when { + muteForever -> MuteState.Forever + until != null -> MuteState.Until(Instant.fromEpochMilliseconds(until)) + else -> null + } + if (mute == null && viewerStateVersion == 0L) return null + return ViewerState(mute = mute, version = viewerStateVersion) + } + // endregion // region ChatMessage diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index 516ad68170..e08e768076 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -13,7 +13,9 @@ import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.services.models.chat.ViewerState import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat import org.junit.Assert.assertEquals @@ -194,6 +196,76 @@ class ChatEntityMapperTest { assertEquals(null, entity.rulesJson) } + @Test + fun `a timed mute is written as its deadline`() { + val entity = mapper.toEntity( + groupMetadata().copy( + viewerState = ViewerState( + mute = MuteState.Until(Instant.fromEpochSeconds(2_000)), + version = 3, + ) + ) + ) + + assertEquals(2_000_000L, entity.muteUntilEpochMs) + assertEquals(false, entity.muteForever) + assertEquals(3L, entity.viewerStateVersion) + } + + @Test + fun `an indefinite mute is written as the flag and no deadline`() { + val entity = mapper.toEntity( + groupMetadata().copy(viewerState = ViewerState(mute = MuteState.Forever, version = 3)) + ) + + assertEquals(null, entity.muteUntilEpochMs) + assertEquals(true, entity.muteForever) + } + + /** + * The deadline survives even once it is in the past. Whether the mute is still in force is + * `isActiveAt(now)`'s answer at render time, not something the mapper resolves on the way + * out — resolving it here would make the row and the model disagree about what is stored. + */ + @Test + fun `a lapsed mute still round-trips as its deadline`() { + val lapsed = Instant.fromEpochSeconds(1_500) + val entity = mapper.toEntity( + groupMetadata().copy(viewerState = ViewerState(mute = MuteState.Until(lapsed), version = 3)) + ) + + val restored = mapper.toMetadata(entity, members = emptyList(), lastMessage = null) + + assertEquals(MuteState.Until(lapsed), restored.viewerState?.mute) + assertEquals(3L, restored.viewerState?.version) + } + + /** + * Unlike `latestEventSequence` above, the version is carried back out: it is what the write + * gate compares, so a metadata rebuilt from a row and pushed back through an upsert has to + * fail the gate rather than re-apply itself over something newer. + */ + @Test + fun `the viewer state version survives the round trip`() { + val entity = mapper.toEntity( + groupMetadata().copy(viewerState = ViewerState(mute = MuteState.Forever, version = 9)) + ) + + val restored = mapper.toMetadata(entity, members = emptyList(), lastMessage = null) + + assertEquals(9L, restored.viewerState?.version) + assertEquals(MuteState.Forever, restored.viewerState?.mute) + } + + @Test + fun `a chat holding nothing about the viewer has no viewer state`() { + val entity = mapper.toEntity(metadata(latestEventSequence = 0)) + + val restored = mapper.toMetadata(entity, members = emptyList(), lastMessage = null) + + assertEquals(null, restored.viewerState) + } + private companion object { const val CHAT_HEX = "aabbccdd" } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MuteExtensions.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MuteExtensions.kt new file mode 100644 index 0000000000..5679b6e861 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MuteExtensions.kt @@ -0,0 +1,22 @@ +package com.flipcash.services.models.chat + +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * Whether the mute is still in force at [now]. + * + * A [MuteState.Until] lapses with no server signal — the client owns the countdown — so the + * deadline is compared against the clock on every read. Resolving it once into a stored or + * rendered boolean would keep a chat muted past the moment it stopped being muted, until some + * unrelated round trip happened to refresh it. + */ +fun MuteState?.isActiveAt(now: Instant = Clock.System.now()): Boolean = when (this) { + null -> false + MuteState.Forever -> true + is MuteState.Until -> until > now +} + +/** Whether the chat is muted for the viewer at [now]. See [isActiveAt]. */ +fun ViewerState?.isMutedAt(now: Instant = Clock.System.now()): Boolean = + this?.mute.isActiveAt(now) diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/PushPayloadMuteMappingTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/PushPayloadMuteMappingTest.kt new file mode 100644 index 0000000000..106633f45d --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/PushPayloadMuteMappingTest.kt @@ -0,0 +1,84 @@ +package com.flipcash.services.internal.network.extensions + +import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel +import com.codeinc.flipcash.gen.common.v1.Common +import com.codeinc.flipcash.gen.push.v1.Model as PushModels +import com.google.protobuf.ByteString +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `push.v1.ChatMetadata.muted` is what silences a notification, and it is the only input to that + * decision — the planner is handed the decoded model, so a mapping that dropped this field would + * leave every planner test passing and every muted chat still audible. This covers the one step + * between the wire and that decision. + */ +class PushPayloadMuteMappingTest { + + private fun payload( + withChatMetadata: Boolean = true, + muted: Boolean? = null, + ): PushModels.Payload { + val builder = PushModels.Payload.newBuilder() + .setNavigation( + PushModels.Navigation.newBuilder() + .setChatId( + Common.ChatId.newBuilder() + .setValue(ByteString.copyFrom(ByteArray(32) { 1 })) + ) + ) + if (withChatMetadata) builder.setChatMetadata(chatMetadata(muted)) + return builder.build() + } + + private fun chatMetadata(muted: Boolean?): PushModels.ChatMetadata = + PushModels.ChatMetadata.newBuilder() + .setType(ChatModel.ChatType.CONTACT_DM) + // Left unset when null, so the default rather than an explicit false is what is read. + .apply { if (muted != null) setMuted(muted) } + .build() + + @Test + fun `a muted push decodes as muted`() { + val decoded = payload(muted = true).asPayload() + assertTrue(decoded.chatMetadata!!.muted) + } + + @Test + fun `an audible push decodes as not muted`() { + val decoded = payload(muted = false).asPayload() + assertFalse(decoded.chatMetadata!!.muted) + } + + /** + * The field is a bare `bool`, so a sender that has not been taught to set it yet is + * indistinguishable from one saying "not muted" — and that is the behaviour we want: an + * unaware server must leave notifications audible, never silence them. + */ + @Test + fun `an unset muted field decodes as not muted`() { + val decoded = payload(muted = null).asPayload() + assertFalse(decoded.chatMetadata!!.muted) + } + + @Test + fun `a push with no chat metadata has none to read`() { + assertNull(payload(withChatMetadata = false).asPayload().chatMetadata) + } + + /** Muting must not disturb anything else the payload carries. */ + @Test + fun `muting changes only the muted flag`() { + val audible = payload(muted = false).asPayload() + val silenced = payload(muted = true).asPayload() + assertEquals(audible.navigation, silenced.navigation) + assertEquals(audible.category, silenced.category) + val audibleChat = audible.chatMetadata!! + val silencedChat = silenced.chatMetadata!! + assertEquals(audibleChat.chatType, silencedChat.chatType) + assertEquals(audibleChat.message, silencedChat.message) + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/MuteLapseTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/MuteLapseTest.kt new file mode 100644 index 0000000000..591199d0b8 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/models/chat/MuteLapseTest.kt @@ -0,0 +1,58 @@ +package com.flipcash.services.models.chat + +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.time.Instant + +/** + * A timed mute lapses with no server signal — the client owns the countdown. Everything that + * reads a mute therefore asks whether it is in force *now*, rather than reading a boolean that + * someone resolved earlier and that has been wrong since the deadline passed. + */ +class MuteLapseTest { + + private val now = Instant.fromEpochSeconds(1_000) + + @Test + fun `a mute whose deadline has passed reads as unmuted`() { + val lapsed = MuteState.Until(Instant.fromEpochSeconds(999)) + + assertEquals(false, lapsed.isActiveAt(now)) + } + + @Test + fun `a mute whose deadline is still ahead reads as muted`() { + val live = MuteState.Until(Instant.fromEpochSeconds(1_001)) + + assertEquals(true, live.isActiveAt(now)) + } + + /** The deadline is the first instant the chat is audible again, not the last it is muted. */ + @Test + fun `a mute reads as lapsed at the deadline itself`() { + val atDeadline = MuteState.Until(now) + + assertEquals(false, atDeadline.isActiveAt(now)) + } + + @Test + fun `an indefinite mute stays in force`() { + assertEquals(true, MuteState.Forever.isActiveAt(now)) + } + + @Test + fun `no mute is not muted`() { + assertEquals(false, (null as MuteState?).isActiveAt(now)) + } + + @Test + fun `viewer state defers to the mute it carries`() { + val lapsed = ViewerState(mute = MuteState.Until(Instant.fromEpochSeconds(999)), version = 4) + val live = ViewerState(mute = MuteState.Until(Instant.fromEpochSeconds(1_001)), version = 4) + + assertEquals(false, lapsed.isMutedAt(now)) + assertEquals(true, live.isMutedAt(now)) + assertEquals(false, ViewerState(mute = null, version = 4).isMutedAt(now)) + assertEquals(false, (null as ViewerState?).isMutedAt(now)) + } +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt index c24fa9a81c..f17616aac7 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/ChoiceRow.kt @@ -13,6 +13,7 @@ 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.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -35,6 +36,25 @@ fun ChoiceRow( icon: Int, modifier: Modifier = Modifier, onClick: () -> Unit, +) { + ChoiceRow( + label = label, + icon = painterResource(icon), + modifier = modifier, + onClick = onClick, + ) +} + +/** + * The same row for a caller whose glyph is not a drawable — a Material vector, say, which has no + * resource id to pass. + */ +@Composable +fun ChoiceRow( + label: String, + icon: Painter, + modifier: Modifier = Modifier, + onClick: () -> Unit, ) { Row( modifier = modifier @@ -48,7 +68,7 @@ fun ChoiceRow( ) { Icon( modifier = Modifier.size(IconSize), - painter = painterResource(icon), + painter = icon, contentDescription = null, tint = CodeTheme.colors.textMain, )