From 4ac6a431de762b2a804cea4a7d7af0560e33aeba Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 18:04:39 -0400 Subject: [PATCH 1/4] fix(chat): gate the message actions on the edit and delete windows The windows landed in `ResolvedUserFlags` with nothing reading them, so the selection bar offered Edit and Delete on any message the viewer sent, however old. The server answers `CANNOT_EDIT` / `CANNOT_DELETE` for those, which is a failed round-trip and an error bar where the action should not have been on offer. `MessagePolicy` now carries both windows, and `ChatViewModel` maps the resolved flags into it and combines it into the transcript. Resolution happens once, when a row is mapped, so the windows keep running under an open transcript: the `ToggleMessageSelection` reducer re-applies them to the bubble it selects, which `withinWindows` shares with the resolver so both give the same answer for the same instant. --- .../features/messenger/build.gradle.kts | 1 + .../app/messenger/internal/ChatViewModel.kt | 48 ++++++++++++- .../flipcash/shared/chat/MessageCapability.kt | 47 ++++++++++--- .../shared/chat/MessageCapabilityTest.kt | 69 +++++++++++++++++++ 4 files changed, 151 insertions(+), 14 deletions(-) diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 7047aac35..a1c88c455 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { implementation(project(":apps:flipcash:shared:menu")) implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:tokens")) + implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":libs:vibrator:bindings")) implementation(project(":libs:messaging")) implementation(project(":services:flipcash")) 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 499f6aeaa..df9e0ee70 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 @@ -19,6 +19,8 @@ import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.extensions.setText import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.shared.chat.MessageCapability +import com.flipcash.shared.chat.MessagePolicy +import com.flipcash.shared.chat.withinWindows import com.flipcash.shared.chat.applying import com.flipcash.shared.chat.resolveCapabilities import com.flipcash.shared.chat.models.ChatListItem @@ -26,6 +28,7 @@ import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.messenger.R import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile @@ -111,6 +114,7 @@ internal class ChatViewModel @Inject constructor( private val resources: ResourceHelper, private val analytics: FlipcashAnalyticsService, private val clipboardManager: ClipboardManager, + private val userFlags: UserFlagsCoordinator, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -170,6 +174,14 @@ internal class ChatViewModel @Inject constructor( * closes, rather than sitting sharp and half-clipped at the sheet's own edge. */ val confirmingDelete: Boolean = false, + /** + * The edit and delete windows the server publishes through `UserFlags`. + * + * Held in state rather than read straight off the coordinator because the reducer needs + * it: a selection has to be narrowed to what is still open at the moment it is made, and + * the reducer is where the selection is set. + */ + val messagePolicy: MessagePolicy = MessagePolicy.Default, ) { // Opening the participant's profile (the entry point to blocking) is only available for tip DMs. val canViewProfile: Boolean @@ -235,6 +247,7 @@ internal class ChatViewModel @Inject constructor( data class LimitsChanged(val limits: Limits?) : Event data class AdvanceReadPointer(val messageId: Long) : Event data class ChatDeactivated(val isReadOnly: Boolean) : Event + data class MessagePolicyChanged(val policy: MessagePolicy) : Event /** Selects [bubble], or leaves selection mode if it is already the selected one. */ data class ToggleMessageSelection(val bubble: ChatListItem.ContentBubble) : Event @@ -272,9 +285,15 @@ internal class ChatViewModel @Inject constructor( .flatMapLatest { chatCoordinator.observeOtherReadPointer(it) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + /** + * Re-runs the transcript mapping when the windows change, so a message resolved under the + * defaults (both open, before the flags arrive) narrows as soon as the server's answer lands. + */ + private val messagePolicy = stateFlow.map { it.messagePolicy }.distinctUntilChanged() + @OptIn(ExperimentalCoroutinesApi::class) val messages: Flow> = - combine(messageStream, pendingMutations) { pagingData, mutations -> + combine(messageStream, pendingMutations, messagePolicy) { pagingData, mutations, policy -> pagingData.flatMap { stored -> val message = stored.applying(mutations[stored.messageId]) message.content.mapIndexed { index, content -> @@ -308,7 +327,7 @@ internal class ChatViewModel @Inject constructor( // Resolved once, here, so no menu re-derives it: a later group-role // taxonomy becomes another input to the resolver rather than a branch at // each action site. - capabilities = resolveCapabilities(message), + capabilities = resolveCapabilities(message, policy), ) } }.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? -> @@ -439,6 +458,19 @@ internal class ChatViewModel @Inject constructor( } private fun initChatHandlers() { + // Ahead of the transcript, so the first mapping already has the real windows rather than + // the defaults, which leave both edit and delete open. + userFlags.resolvedFlags + .map { + MessagePolicy( + editWindow = it.messageEditWindow.effectiveValue, + deleteWindow = it.messageDeleteWindow.effectiveValue, + ) + } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.MessagePolicyChanged(it)) } + .launchIn(viewModelScope) + // Unified chat open handler — resolves chatId and contact from the identifier eventFlow .filterIsInstance() @@ -1168,10 +1200,20 @@ internal class ChatViewModel @Inject constructor( is Event.LimitsChanged -> { state -> state.copy(limits = event.limits) } is Event.AdvanceReadPointer -> { state -> state } is Event.ChatDeactivated -> { state -> state.copy(isAnonymous = event.isReadOnly) } + is Event.MessagePolicyChanged -> { state -> state.copy(messagePolicy = event.policy) } is Event.ToggleMessageSelection -> { state -> val alreadySelected = state.selection?.itemKey == event.bubble.itemKey + // The transcript resolved this bubble when it was mapped, which may have been + // well inside a window that has since closed. Narrow it again here so the bar + // offers what is open now rather than what was open when the row was built. + val selected = event.bubble.takeUnless { alreadySelected }?.let { bubble -> + bubble.copy( + capabilities = bubble.capabilities + .withinWindows(bubble.timestamp, state.messagePolicy), + ) + } state.copy( - selection = event.bubble.takeUnless { alreadySelected }, + selection = selected, confirmingDelete = false, ) } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt index 001741127..8265cd96f 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt @@ -29,12 +29,17 @@ enum class MessageCapability { /** * Client-side limits on what may be done to a message. * - * @param editWindow how long after sending a message stays editable, or `null` for no limit. The - * server does not publish a window today, so the default leaves edit open and lets `CANNOT_EDIT` - * be the authority. + * Both windows come from `UserFlags` (`message_edit_window`, `message_delete_window`), which sends + * them with explicit presence: an unset field is no limit rather than a zero-length one. The + * defaults are therefore both `null`, which leaves `CANNOT_EDIT` / `CANNOT_DELETE` as the + * authority for a build that has not seen the flags yet. + * + * @param editWindow how long after sending a message stays editable, or `null` for no limit. + * @param deleteWindow how long after sending a message stays deletable, or `null` for no limit. */ data class MessagePolicy( val editWindow: Duration? = null, + val deleteWindow: Duration? = null, ) { companion object { val Default = MessagePolicy() @@ -46,8 +51,9 @@ data class MessagePolicy( * * | Message | Capabilities | * |---|---| - * | Own text, confirmed, within the edit window | Copy, Reply, Edit, Delete | - * | Own text, confirmed, outside a configured window | Copy, Reply, Delete | + * | Own text, confirmed, inside both windows | Copy, Reply, Edit, Delete | + * | Own text, confirmed, past the edit window | Copy, Reply, Delete | + * | Own text, confirmed, past both windows | Copy, Reply | * | Own text, unconfirmed (`eventSequence == 0`) | none | * | Another participant's text | Copy, Reply | * | Any cash or tip message | Reply | @@ -85,14 +91,33 @@ fun resolveCapabilities( if (hasText) add(MessageCapability.Copy) add(MessageCapability.Reply) if (message.isFromSelf) { - if (hasText && policy.allowsEdit(message, now)) add(MessageCapability.Edit) + if (hasText) add(MessageCapability.Edit) add(MessageCapability.Delete) } - } + }.withinWindows(message.timestamp, policy, now) } -/** True while [message] is still inside the configured edit window, or always if there is none. */ -private fun MessagePolicy.allowsEdit(message: ChatMessage, now: Instant): Boolean { - val window = editWindow ?: return true - return now - message.timestamp <= window +/** + * Drops the capabilities of a message sent at [sentAt] whose window has since closed. + * + * Split out of [resolveCapabilities] because resolution happens once, when the transcript is + * mapped, and the windows keep running afterwards: a menu opened a minute later would otherwise + * still offer an edit the server is about to answer `CANNOT_EDIT`. A surface holding an + * already-resolved set re-applies this when it acts on it, and gets the same answer the resolver + * would give — the rule lives in one place either way. + */ +fun Set.withinWindows( + sentAt: Instant, + policy: MessagePolicy, + now: Instant = Clock.System.now(), +): Set = filterTo(mutableSetOf()) { capability -> + when (capability) { + MessageCapability.Edit -> policy.editWindow.stillOpen(sentAt, now) + MessageCapability.Delete -> policy.deleteWindow.stillOpen(sentAt, now) + MessageCapability.Copy, MessageCapability.Reply -> true + } } + +/** True while a message sent at [sentAt] is inside this window, or always if there is none. */ +private fun Duration?.stillOpen(sentAt: Instant, now: Instant): Boolean = + this == null || now - sentAt <= this diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt index 08d029064..1cc790b0e 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt @@ -8,6 +8,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.minutes import kotlin.time.Instant @@ -138,4 +139,72 @@ class MessageCapabilityTest { resolveCapabilities(text(), policy, now = sentAt + 16.minutes), ) } + + @Test + fun `a delete window drops Delete once it lapses and leaves Edit alone`() { + val policy = MessagePolicy(deleteWindow = 60.minutes) + + assertEquals( + setOf( + MessageCapability.Copy, + MessageCapability.Reply, + MessageCapability.Edit, + MessageCapability.Delete, + ), + resolveCapabilities(text(), policy, now = sentAt + 59.minutes), + ) + + // Edit survives: this policy sets no edit window, and an unset window is no limit. + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Edit), + resolveCapabilities(text(), policy, now = sentAt + 61.minutes), + ) + } + + @Test + fun `the windows run independently, so the shorter one lapses first`() { + val policy = MessagePolicy(editWindow = 15.minutes, deleteWindow = 60.minutes) + + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete), + resolveCapabilities(text(), policy, now = sentAt + 30.minutes), + ) + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply), + resolveCapabilities(text(), policy, now = sentAt + 90.minutes), + ) + } + + @Test + fun `an unset window leaves its capability open`() { + assertEquals( + setOf( + MessageCapability.Copy, + MessageCapability.Reply, + MessageCapability.Edit, + MessageCapability.Delete, + ), + resolveCapabilities(text(), MessagePolicy.Default, now = sentAt + 365.days), + ) + } + + /** + * The transcript resolves once, when it is mapped; the menu re-applies the windows when it + * opens. Both go through the same rule, so a set narrowed after the fact matches what the + * resolver would have returned at that instant. + */ + @Test + fun `re-applying the windows to a resolved set matches resolving at that instant`() { + val policy = MessagePolicy(editWindow = 15.minutes, deleteWindow = 60.minutes) + val atSend = resolveCapabilities(text(), policy, now = sentAt) + + assertEquals( + resolveCapabilities(text(), policy, now = sentAt + 30.minutes), + atSend.withinWindows(sentAt, policy, now = sentAt + 30.minutes), + ) + assertEquals( + resolveCapabilities(text(), policy, now = sentAt + 90.minutes), + atSend.withinWindows(sentAt, policy, now = sentAt + 90.minutes), + ) + } } From 56610e67e69311cd95ce93994bcb1e4abc5242b9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 18:04:44 -0400 Subject: [PATCH 2/4] feat(userflags): make the message edit and delete windows overridable Watching the menu narrow means waiting out whatever the server publishes. Both windows are now `Field`s on the User Flags screen, entered in seconds like the bill exchange timeout above them, so a window short enough to observe is one text field away. --- .../userflags/internal/UserFlagsViewModel.kt | 2 + .../com/flipcash/app/userflags/Field.kt | 39 +++++++++++++++++++ .../app/userflags/ResolvedUserFlags.kt | 5 +-- .../app/userflags/UserFlagsCoordinator.kt | 6 +++ .../userflags/src/main/res/values/strings.xml | 4 ++ 5 files changed, 53 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt b/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt index 76e8c21a3..70ee49587 100644 --- a/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt +++ b/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt @@ -188,4 +188,6 @@ private fun ResolvedUserFlags.editableEntries(): List> = listOf EditableEntry(Field.PreferredUsdcOnRampLiquidityPool, usdcOnRampLiquidityPool), EditableEntry(Field.MinimumHolderAmountForLeaderboard, minimumHolderAmountForLeaderboard), EditableEntry(Field.RequireCoinbaseEmailVerification, requireCoinbaseEmailVerification), + EditableEntry(Field.MessageEditWindow, messageEditWindow), + EditableEntry(Field.MessageDeleteWindow, messageDeleteWindow), ) \ No newline at end of file diff --git a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt index e22258694..833a8bf16 100644 --- a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt @@ -100,6 +100,45 @@ sealed class Field( }, ) + /** + * Both message windows are entered in seconds, like the bill timeout above, because the values + * worth testing are short: the point of overriding one is to watch a menu narrow while the + * transcript is open, not to reproduce the server's hour. + */ + data object MessageEditWindow : Field( + longPreferencesKey("override_message_edit_window"), + encode = { it?.inWholeMilliseconds ?: -1L }, + decode = { if (it < 0) null else it.milliseconds }, + label = R.string.label_flag_messageEditWindow, + hint = R.string.hint_flag_messageEditWindow, + format = { it?.let { "${it.inWholeSeconds}s" } ?: "None" }, + editFormat = { it?.inWholeSeconds?.toString() ?: "" }, + editor = FieldEditor.TextInput( + keyboard = KeyboardType.Number, + parse = { it.toLongOrNull()?.let { secs -> (secs * 1000).milliseconds } }, + ), + inputTransformation = InputTransformation { + if (!asCharSequence().all { it.isDigit() }) revertAllChanges() + }, + ) + + data object MessageDeleteWindow : Field( + longPreferencesKey("override_message_delete_window"), + encode = { it?.inWholeMilliseconds ?: -1L }, + decode = { if (it < 0) null else it.milliseconds }, + label = R.string.label_flag_messageDeleteWindow, + hint = R.string.hint_flag_messageDeleteWindow, + format = { it?.let { "${it.inWholeSeconds}s" } ?: "None" }, + editFormat = { it?.inWholeSeconds?.toString() ?: "" }, + editor = FieldEditor.TextInput( + keyboard = KeyboardType.Number, + parse = { it.toLongOrNull()?.let { secs -> (secs * 1000).milliseconds } }, + ), + inputTransformation = InputTransformation { + if (!asCharSequence().all { it.isDigit() }) revertAllChanges() + }, + ) + data object NewCurrencyPurchaseAmount : Field( longPreferencesKey("override_new_currency_amount"), encode = { it.quarks }, diff --git a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt index a56be7070..06145270b 100644 --- a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt @@ -58,7 +58,6 @@ internal fun UserFlags.resolve(overrides: Overrides): ResolvedUserFlags = Resolv requireCoinbaseEmailVerification = ResolvedFlag(requireCoinbaseEmailVerification, overrides.requireCoinbaseEmailVerification), tipPresets = ResolvedFlag(tipPresets, FieldOverride.None), usernameMinBalance = ResolvedFlag(usernameMinBalance, FieldOverride.None), - // Read-only for now — no debug override support until the edit/delete UI lands. - messageEditWindow = ResolvedFlag(messageEditWindow, FieldOverride.None), - messageDeleteWindow = ResolvedFlag(messageDeleteWindow, FieldOverride.None), + messageEditWindow = ResolvedFlag(messageEditWindow, overrides.messageEditWindow), + messageDeleteWindow = ResolvedFlag(messageDeleteWindow, overrides.messageDeleteWindow), ) \ No newline at end of file diff --git a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt index 395b25986..04a5c91e0 100644 --- a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt @@ -53,6 +53,8 @@ class UserFlagsCoordinator @Inject constructor( val preferredUsdcOnRampLiquidityPool: FieldOverride, val minimumHolderAmountForLeaderboard: FieldOverride, val requireCoinbaseEmailVerification: FieldOverride, + val messageEditWindow: FieldOverride, + val messageDeleteWindow: FieldOverride, ) { companion object { val None = Overrides( @@ -66,6 +68,8 @@ class UserFlagsCoordinator @Inject constructor( preferredUsdcOnRampLiquidityPool = FieldOverride.None, minimumHolderAmountForLeaderboard = FieldOverride.None, requireCoinbaseEmailVerification = FieldOverride.None, + messageEditWindow = FieldOverride.None, + messageDeleteWindow = FieldOverride.None, ) } } @@ -110,6 +114,8 @@ class UserFlagsCoordinator @Inject constructor( preferredUsdcOnRampLiquidityPool = prefs.readOverride(Field.PreferredUsdcOnRampLiquidityPool), minimumHolderAmountForLeaderboard = prefs.readOverride(Field.MinimumHolderAmountForLeaderboard), requireCoinbaseEmailVerification = prefs.readOverride(Field.RequireCoinbaseEmailVerification), + messageEditWindow = prefs.readOverride(Field.MessageEditWindow), + messageDeleteWindow = prefs.readOverride(Field.MessageDeleteWindow), ) }.stateIn(scope, SharingStarted.Eagerly, Overrides.None) diff --git a/apps/flipcash/shared/userflags/src/main/res/values/strings.xml b/apps/flipcash/shared/userflags/src/main/res/values/strings.xml index d1966630a..6cb59d453 100644 --- a/apps/flipcash/shared/userflags/src/main/res/values/strings.xml +++ b/apps/flipcash/shared/userflags/src/main/res/values/strings.xml @@ -23,4 +23,8 @@ Enter amount Tip Presets Username Minimum Balance + Message Edit Window + Enter time in seconds + Message Delete Window + Enter time in seconds \ No newline at end of file From c3b31491e31413c233534b5c1ba5f95b1339f4ee Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 18:04:55 -0400 Subject: [PATCH 3/4] fix(ui): keep the bottom bar above the keyboard Every screen is drawn edge to edge, so the window is not resized when the IME opens. The bottom bar is bottom-aligned in that unresized window, which put it behind the keyboard: a chat edit failure, raised while the composer still holds focus, showed an error the user could not see. `imePadding` on the animated content lifts the bar with the keyboard. The scrim is left alone so it still covers the whole window. --- .../com/getcode/ui/components/bars/BottomBarContainer.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt index 0ceb1c435..979d5001b 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material.Icon import androidx.compose.material.LocalContentColor @@ -157,8 +158,13 @@ fun BottomBarContainer( } AnimatedContent( + // The bar rides the keyboard. Every screen is drawn edge to edge, so the window is not + // resized when the IME opens and a bottom-aligned bar would otherwise slide up underneath + // it — a chat error, raised while the composer still has focus, arrived invisible. The + // scrim above deliberately keeps covering the whole window, keyboard included. modifier = Modifier .fillMaxSize() + .imePadding() .clipToBounds(), targetState = bottomBarVisibleState.targetState, transitionSpec = { From 39904828bcaf5149c4043459558588acc8a6a90f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 18:04:55 -0400 Subject: [PATCH 4/4] fix(chat): carry the server's event sequence onto a confirmed message Long-pressing a message just sent did nothing; leaving the chat and coming back made it work. Sending writes an optimistic row with `event_sequence` 0, because only the server can stamp it, and `confirmPendingMessage` then updated the id, timestamp, unread sequence and status but not the sequence. The row stayed at 0 until some later fetch of the chat overwrote it. Everything keyed on that stamp read the message as unacknowledged in the meantime. `resolveCapabilities` returns nothing for sequence 0, so the row was not selectable, and `expected_event_sequence` is validated `>= 1` on the wire, so an edit or delete built from the stale value could not have been valid either. The confirm now writes the sequence from the send echo, which carries it. --- .../app/persistence/dao/ChatMessageDao.kt | 9 +++++ .../app/persistence/dao/ChatMessageDaoTest.kt | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index 6238920d9..0a36d103b 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -103,11 +103,18 @@ interface ChatMessageDao { @Query("DELETE FROM chat_messages WHERE chat_id_hex = :chatIdHex AND status = 'SENDING'") suspend fun deleteAllPending(chatIdHex: String) + /** + * The optimistic row is written before the server has stamped the message, so every + * server-assigned field is written here — `event_sequence` included. Leaving it at the pending + * row's 0 would keep a sent message looking unacknowledged until some later fetch of the chat + * overwrote the row, which is what the edit/delete guards and last-writer-wins read it for. + */ @Query(""" UPDATE chat_messages SET message_id = :newMessageId, timestamp_epoch_ms = :newTimestampMs, unread_seq = :newUnreadSeq, + event_sequence = :newEventSequence, status = 'SENT' WHERE chat_id_hex = :chatIdHex AND pending_client_id_hex = :clientIdHex """) @@ -117,6 +124,7 @@ interface ChatMessageDao { newMessageId: Long, newTimestampMs: Long, newUnreadSeq: Long, + newEventSequence: Long, ) @Transaction @@ -127,6 +135,7 @@ interface ChatMessageDao { newMessageId = serverMessage.messageId, newTimestampMs = serverMessage.timestampEpochMs, newUnreadSeq = serverMessage.unreadSeq, + newEventSequence = serverMessage.eventSequence, ) } diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index 20ac4e4fe..601b4ca95 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -6,6 +6,7 @@ import androidx.test.core.app.ApplicationProvider import com.flipcash.app.persistence.FlipcashDatabase import com.flipcash.app.persistence.converters.MessageContentSerialized import com.flipcash.app.persistence.entities.ChatMessageEntity +import com.flipcash.app.persistence.entities.MessageStatus import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before @@ -49,6 +50,17 @@ class ChatMessageDaoTest { unreadSeq = messageId, ) + private fun pending(body: String) = ChatMessageEntity( + chatIdHex = CHAT_HEX, + messageId = -1, + senderIdHex = SENDER_HEX, + contentJson = listOf(MessageContentSerialized.Text(body)), + timestampEpochMs = 1, + unreadSeq = 0, + status = MessageStatus.SENDING, + pendingClientIdHex = CLIENT_HEX, + ) + private fun tombstone(messageId: Long) = text(messageId, "gone").copy( contentJson = listOf(MessageContentSerialized.Deleted(deletedAt = 1, deletedBy = SENDER_HEX)), isDeleted = true, @@ -103,9 +115,32 @@ class ChatMessageDaoTest { assertEquals(2L, dao.getLatestVisible(CHAT_HEX)?.messageId) } + /** + * The optimistic row is written with no event sequence, because the client has none to write: + * the server stamps it. Confirming has to carry the echo's stamp onto the row, or the message + * stays at sequence 0 until something else refetches the chat — and everything keyed on the + * stamp (edit, delete, last-writer-wins) treats it as unacknowledged in the meantime. + */ + @Test + fun `confirming a pending message carries the server's event sequence`() = runTest { + dao.upsert(pending("hello")) + + dao.confirmPendingMessage( + CHAT_HEX, + CLIENT_HEX, + text(7, "hello").copy(eventSequence = 42, unreadSeq = 3), + ) + + val stored = dao.getMessage(CHAT_HEX, 7)!! + assertEquals(42L, stored.eventSequence) + assertEquals(MessageStatus.SENT, stored.status) + assertEquals(3L, stored.unreadSeq) + } + private companion object { const val CHAT_HEX = "aabb" const val OTHER_HEX = "ccdd" const val SENDER_HEX = "1122" + const val CLIENT_HEX = "eeff" } }