From aaf0a1220a19b6a61249ffb9e4f59da2d99b89e1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:22:11 -0400 Subject: [PATCH 1/7] feat(chat): gate Edit and Delete on the server's message windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveCapabilities` gated Edit on a window nothing ever supplied and added Delete unconditionally, so both actions were offered on messages of any age and `CANNOT_EDIT` / `CANNOT_DELETE` were the only thing stopping them. `MessagePolicy` now carries `deleteWindow` alongside `editWindow`, and `MessagePolicy.from` substitutes 15 minutes / 48 hours for either window the server leaves unset. That covers a failed flags fetch by the same path: `UserFlagsCoordinator.resolvedFlags` falls back to `UserFlags.Default`, whose windows are `null`. The fallback lives at policy construction rather than in `UserFlagsMapper`, so `null` keeps meaning "the server said nothing" for anything else reading the flags. Both constants are named on the companion — iOS applies the same two values, and a test asserts them so a drift fails rather than silently splitting the platforms. This inverts what the old KDoc documented: a server that sends nothing now has the client's window imposed on it, and an edit it would have accepted at 20 minutes is hidden at 15. The KDoc is rewritten to state that trade-off. The server stays the authority for anything the client does offer. `ChatViewModel` builds the policy from `ResolvedUserFlags` and passes a real `now`. Because capabilities are resolved inside a paging map, a window lapsing produced no re-emission and a row kept Edit indefinitely — reachable in one sitting at 15 minutes. A 30-second poll re-runs the mapping, which is cheap: it sits below `cachedIn`, so nothing re-fetches. An already-open selection bar keeps the capabilities it captured at long-press. --- .../features/messenger/build.gradle.kts | 1 + .../app/messenger/internal/ChatViewModel.kt | 78 +++++++- .../flipcash/shared/chat/MessageCapability.kt | 77 +++++++- .../shared/chat/MessageCapabilityTest.kt | 184 ++++++++++++++---- 4 files changed, 293 insertions(+), 47 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..4ed03a73e 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,7 @@ 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.applying import com.flipcash.shared.chat.resolveCapabilities import com.flipcash.shared.chat.models.ChatListItem @@ -26,6 +27,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 @@ -71,6 +73,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -82,6 +85,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.math.min +import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -108,6 +112,7 @@ internal class ChatViewModel @Inject constructor( private val verifiedFiatCalculator: VerifiedFiatCalculator, private val purchaseMethodController: PurchaseMethodController, private val userManager: UserManager, + userFlags: UserFlagsCoordinator, private val resources: ResourceHelper, private val analytics: FlipcashAnalyticsService, private val clipboardManager: ClipboardManager, @@ -272,9 +277,62 @@ internal class ChatViewModel @Inject constructor( .flatMapLatest { chatCoordinator.observeOtherReadPointer(it) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + /** + * The edit/delete windows the transcript gates on. + * + * `resolvedFlags` is a `StateFlow` seeded with `UserFlags.Default`, and falls back to it + * whenever the server flags are absent — a failed fetch, or the window before the first one + * lands. `UserFlags.Default` carries `null` for both windows, so the failed-fetch case arrives + * here as the same `null` an explicitly-unset server field would, and [MessagePolicy.from] + * substitutes the fallback window for both without a second branch. + */ + private val messagePolicy = userFlags.resolvedFlags + .map { + MessagePolicy.from( + editWindow = it.messageEditWindow.effectiveValue, + deleteWindow = it.messageDeleteWindow.effectiveValue, + ) + } + .distinctUntilChanged() + + /** + * Drives re-resolution of capabilities so a row loses Edit and Delete when its window closes. + * + * Capabilities are resolved once per mapping pass, so without this a message resolved at send + * time keeps Edit forever: nothing upstream re-emits when a window merely lapses. With a + * 15-minute default edit window that is an ordinary session, not a corner case — leave a chat + * open, scroll back, and the menu offers an edit the server will reject. + * + * A poll rather than a timer armed at each message's expiry: the transcript is paged, so the + * set of loaded messages (and therefore the next expiry) changes as the user scrolls, and + * tracking that is more machinery than the problem is worth. The cost of the poll is bounded — + * it re-runs the mapping, not the fetch, because [messageStream] is cached above it, and the + * token metadata the mapping enriches with is memory-cached. The cost of the interval is up to + * [CapabilityRefreshInterval] of staleness at each boundary, during which a lapsed row still + * offers its action and the server answers `CANNOT_EDIT` / `CANNOT_DELETE`. That is the same + * race the gating cannot close anyway: a menu resolved a moment before expiry is stale by the + * time it is tapped whatever the interval. + * + * This covers the transcript, not an already-open selection bar — `State.selection` holds the + * bubble captured at long-press, and keeps the capabilities it was captured with. Selection is + * a few seconds of user attention rather than a row parked on screen, so it is left to the + * server error. + */ + private val capabilityClock = flow { + while (true) { + emit(Clock.System.now()) + delay(CapabilityRefreshInterval) + } + } + @OptIn(ExperimentalCoroutinesApi::class) val messages: Flow> = - combine(messageStream, pendingMutations) { pagingData, mutations -> + combine( + messageStream, + pendingMutations, + messagePolicy, + capabilityClock, + ) { pagingData, mutations, policy, now -> pagingData.flatMap { stored -> val message = stored.applying(mutations[stored.messageId]) message.content.mapIndexed { index, content -> @@ -305,10 +363,11 @@ internal class ChatViewModel @Inject constructor( // A null author is a moderation removal, which reads as someone else's. deletedByViewer = (enriched as? MessageContent.Deleted)?.deletedBy ?.let { it == userManager.accountId } == true, - // 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), + // Resolved 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. Re-resolved on every pass rather than once per message, + // because `policy` and `now` both move — see `capabilityClock`. + capabilities = resolveCapabilities(message, policy, now), ) } }.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? -> @@ -1082,6 +1141,15 @@ internal class ChatViewModel @Inject constructor( } companion object { + /** + * How often the transcript re-resolves capabilities so lapsed windows drop their actions. + * + * Coarse on purpose. It bounds how long a lapsed row keeps offering an action, and the + * shortest window it has to bound is the 15-minute edit default, so seconds of slack cost + * nothing a user can act on faster than the server can answer. + */ + private val CapabilityRefreshInterval = 30.seconds + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { is Event.OnChatOpened -> { state -> 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..0510bffe2 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 @@ -4,6 +4,8 @@ import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.MessageContent import kotlin.time.Clock import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes import kotlin.time.Instant /** @@ -29,15 +31,57 @@ 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. + * The server publishes both windows in `account.v1.UserFlags` with explicit presence, so an unset + * field reaches the client as `null` meaning "the server said nothing" — not "no limit". [from] + * turns that silence into [FallbackEditWindow] / [FallbackDeleteWindow] rather than leaving the + * action open. The same substitution covers a failed flags fetch without a second code path: + * `UserFlagsCoordinator.resolvedFlags` falls back to `UserFlags.Default` whenever the server flags + * are absent, and `UserFlags.Default` carries `null` for both windows. + * + * This inverts the rule this class shipped with, which left edit open and made the server's + * `CANNOT_EDIT` the sole authority. What that bought was never offering less than the server would + * allow; what it cost was a menu that confidently offers Edit on a week-old message and fails on + * tap. The fallback trades one for the other: with it in force a server that sends nothing has the + * client's window imposed on it, so an edit the server would have accepted 20 minutes after sending + * is hidden at 15. That is the cheaper failure — a missing row is legible, a row that errors on tap + * is not — and the inversion is only in what the client *offers*. The server remains the authority + * for everything the client does offer: `CANNOT_EDIT` and `CANNOT_DELETE` still backstop the gap + * between resolving a menu and the request landing. + * + * A `null` window still means no limit, and is now reachable only by naming it — [from] never + * produces one, and both defaults are windows rather than `null`, so a call site that forgets to + * pass a policy is gated rather than unbounded. + * + * @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 editWindow: Duration? = FallbackEditWindow, + val deleteWindow: Duration? = FallbackDeleteWindow, ) { companion object { + /** + * Applied when `message_edit_window` is unset or the flags fetch failed. + * + * Named rather than inlined so the value is greppable from both platforms: iOS applies the + * same 15 minutes, and the two have to agree for the same message to resolve the same + * capability set. + */ + val FallbackEditWindow: Duration = 15.minutes + + /** Applied when `message_delete_window` is unset or the flags fetch failed. iOS: 48 hours. */ + val FallbackDeleteWindow: Duration = 48.hours + val Default = MessagePolicy() + + /** + * Builds a policy from the server's windows, substituting the fallback for either one the + * server left unset. + */ + fun from(editWindow: Duration?, deleteWindow: Duration?) = MessagePolicy( + editWindow = editWindow ?: FallbackEditWindow, + deleteWindow = deleteWindow ?: FallbackDeleteWindow, + ) } } @@ -46,12 +90,16 @@ 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, within 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 | * | A tombstone | none | + * + * [now] is a parameter rather than read inline so a caller can re-resolve a message it is already + * showing: the set is a function of the clock, and a row that resolved Edit does not keep it. */ fun resolveCapabilities( message: ChatMessage, @@ -86,13 +134,24 @@ fun resolveCapabilities( add(MessageCapability.Reply) if (message.isFromSelf) { if (hasText && policy.allowsEdit(message, now)) add(MessageCapability.Edit) - add(MessageCapability.Delete) + if (policy.allowsDelete(message, now)) add(MessageCapability.Delete) } } } /** 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 +private fun MessagePolicy.allowsEdit(message: ChatMessage, now: Instant): Boolean = + editWindow.admits(message, now) + +/** True while [message] is still inside the configured delete window, or always if there is none. */ +private fun MessagePolicy.allowsDelete(message: ChatMessage, now: Instant): Boolean = + deleteWindow.admits(message, now) + +/** + * Both windows are measured from the send time, not from the last edit, and both are inclusive: + * a message at exactly the window length is still actionable. iOS matches on both counts. + */ +private fun Duration?.admits(message: ChatMessage, now: Instant): Boolean { + val window = this ?: return true return now - message.timestamp <= window } 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..6a8fba8f7 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,7 +8,10 @@ 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.hours import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant /** @@ -24,6 +27,13 @@ class MessageCapabilityTest { private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa") private val sentAt = Instant.fromEpochSeconds(1_000) + private val everything = setOf( + MessageCapability.Copy, + MessageCapability.Reply, + MessageCapability.Edit, + MessageCapability.Delete, + ) + private fun message( content: List, isFromSelf: Boolean = true, @@ -52,55 +62,58 @@ class MessageCapabilityTest { isFromSelf, ) + /** + * Resolves at send time by default. Every window is open at that instant, so the table tests + * below assert content and authorship without the clock entering into it; the window tests + * pass their own [now]. + */ + private fun capabilities( + message: ChatMessage, + policy: MessagePolicy = MessagePolicy.Default, + now: Instant = sentAt, + ) = resolveCapabilities(message, policy, now) + @Test fun `own text message is copyable, editable and deletable`() { - assertEquals( - setOf( - MessageCapability.Copy, - MessageCapability.Reply, - MessageCapability.Edit, - MessageCapability.Delete, - ), - resolveCapabilities(text()), - ) + assertEquals(everything, capabilities(text())) } @Test fun `someone else's text message is copyable but not editable or deletable`() { assertEquals( setOf(MessageCapability.Copy, MessageCapability.Reply), - resolveCapabilities(text(isFromSelf = false)), + capabilities(text(isFromSelf = false)), ) } @Test fun `cash is never editable or deletable, by either party`() { - assertEquals(setOf(MessageCapability.Reply), resolveCapabilities(cash())) - assertEquals(setOf(MessageCapability.Reply), resolveCapabilities(cash(isFromSelf = false))) + assertEquals(setOf(MessageCapability.Reply), capabilities(cash())) + assertEquals(setOf(MessageCapability.Reply), capabilities(cash(isFromSelf = false))) } @Test fun `a tombstone offers nothing`() { val deleted = message(listOf(MessageContent.Deleted(sentAt, selfId))) - assertEquals(emptySet(), resolveCapabilities(deleted)) + assertEquals(emptySet(), capabilities(deleted)) } @Test fun `an unconfirmed message offers nothing`() { // `expected_event_sequence` is validated `>= 1`, so no valid edit or delete request exists // for a message the server has not acknowledged yet. - assertEquals(emptySet(), resolveCapabilities(text(eventSequence = 0))) + assertEquals(emptySet(), capabilities(text(eventSequence = 0))) } @Test fun `system notices are not a participant's message`() { val system = message(listOf(MessageContent.System("Anna joined"))) - assertEquals(emptySet(), resolveCapabilities(system)) + assertEquals(emptySet(), capabilities(system)) } @Test fun `empty content offers nothing`() { - assertEquals(emptySet(), resolveCapabilities(message(emptyList()))) + assertEquals(emptySet(), capabilities(message(emptyList()))) } @Test @@ -108,34 +121,139 @@ class MessageCapabilityTest { val reply = message( listOf(MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text("hi")))), ) + assertEquals(everything, capabilities(reply)) + } + + // --- Windows --- + + @Test + fun `an edit window drops Edit once it lapses and leaves Delete alone`() { + val policy = MessagePolicy(editWindow = 15.minutes, deleteWindow = null) + + assertEquals(everything, capabilities(text(), policy, now = sentAt + 14.minutes)) + assertEquals( - setOf( - MessageCapability.Copy, - MessageCapability.Reply, - MessageCapability.Edit, - MessageCapability.Delete, - ), - resolveCapabilities(reply), + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete), + capabilities(text(), policy, now = sentAt + 16.minutes), ) } @Test - fun `an edit window drops Edit once it lapses and leaves Delete alone`() { - val policy = MessagePolicy(editWindow = 15.minutes) + fun `a delete window drops Delete once it lapses`() { + val policy = MessagePolicy(editWindow = null, deleteWindow = 48.hours) + + assertEquals(everything, capabilities(text(), policy, now = sentAt + 47.hours)) assertEquals( - setOf( - MessageCapability.Copy, - MessageCapability.Reply, - MessageCapability.Edit, - MessageCapability.Delete, - ), - resolveCapabilities(text(), policy, now = sentAt + 14.minutes), + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Edit), + capabilities(text(), policy, now = sentAt + 49.hours), ) + } + @Test + fun `both windows lapsing leaves only what anyone may do`() { + val policy = MessagePolicy(editWindow = 15.minutes, deleteWindow = 48.hours) + + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply), + capabilities(text(), policy, now = sentAt + 49.hours), + ) + } + + @Test + fun `a message at exactly the window length is still actionable`() { + // Inclusive on both windows, and iOS matches. A message resolved at the boundary instant + // keeps its actions; it loses them one tick later. + val policy = MessagePolicy(editWindow = 15.minutes, deleteWindow = 48.hours) + + assertEquals(everything, capabilities(text(), policy, now = sentAt + 15.minutes)) assertEquals( setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete), - resolveCapabilities(text(), policy, now = sentAt + 16.minutes), + capabilities(text(), policy, now = sentAt + 15.minutes + 1.seconds), + ) + + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete), + capabilities(text(), policy, now = sentAt + 48.hours), + ) + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply), + capabilities(text(), policy, now = sentAt + 48.hours + 1.seconds), + ) + } + + @Test + fun `a null window means no limit`() { + // Only reachable by naming it. `from` never produces one, so this is the shape a test or a + // deliberate opt-out gets, not anything the server can hand us. + val unlimited = MessagePolicy(editWindow = null, deleteWindow = null) + + assertEquals(everything, capabilities(text(), unlimited, now = sentAt + 365.days)) + } + + @Test + fun `the fallback windows are what iOS applies`() { + // Guards the half of parity that is a value rather than a branch: the two platforms have to + // substitute the same durations for the same message to resolve the same capability set. + assertEquals(15.minutes, MessagePolicy.FallbackEditWindow) + assertEquals(48.hours, MessagePolicy.FallbackDeleteWindow) + } + + @Test + fun `unset server windows fall back rather than leaving the action open`() { + // What `UserFlags.Default` produces: the fetch failed, or the server sent neither field. + // Both arrive as null and both get the client's window, so the flags being absent gates + // the menu instead of opening it. + val fallback = MessagePolicy.from(editWindow = null, deleteWindow = null) + + assertEquals(everything, capabilities(text(), fallback, now = sentAt + 15.minutes)) + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete), + capabilities(text(), fallback, now = sentAt + 15.minutes + 1.seconds), + ) + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply), + capabilities(text(), fallback, now = sentAt + 48.hours + 1.seconds), + ) + } + + @Test + fun `a window the server does publish wins over the fallback`() { + val policy = MessagePolicy.from(editWindow = 1.hours, deleteWindow = 2.hours) + + // Past the 15-minute fallback but inside the server's hour. + assertEquals(everything, capabilities(text(), policy, now = sentAt + 30.minutes)) + // Past the server's windows, well short of the 48-hour delete fallback. + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply), + capabilities(text(), policy, now = sentAt + 3.hours), + ) + } + + @Test + fun `an unset window falls back independently of one the server did publish`() { + val policy = MessagePolicy.from(editWindow = 1.hours, deleteWindow = null) + + assertEquals(1.hours, policy.editWindow) + assertEquals(MessagePolicy.FallbackDeleteWindow, policy.deleteWindow) + } + + @Test + fun `the default policy is the fallback policy`() { + // A call site that forgets to pass a policy is gated, not unbounded. + assertEquals(MessagePolicy.FallbackEditWindow, MessagePolicy.Default.editWindow) + assertEquals(MessagePolicy.FallbackDeleteWindow, MessagePolicy.Default.deleteWindow) + } + + @Test + fun `windows never grant a capability the message does not have`() { + val policy = MessagePolicy(editWindow = null, deleteWindow = null) + + // Someone else's message stays theirs however open the windows are, and cash stays cash. + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply), + capabilities(text(isFromSelf = false), policy), ) + assertEquals(setOf(MessageCapability.Reply), capabilities(cash(), policy)) } } From 933c4444e81819ee56cfdbe12f0ded499ed21a0d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 16:48:08 -0400 Subject: [PATCH 2/7] feat(chat): name the closed window when an edit or delete is rejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capability gate hides Edit and Delete once their windows close, but it cannot close the gap between opening the edit composer inside the window and submitting outside it, or between the delete sheet appearing and being confirmed. Both land as CANNOT_EDIT / CANNOT_DELETE, which until now showed the generic "your change couldn't be saved" copy — accurate but unhelpful, since nothing went wrong and retrying will not help. Branch those two results onto copy that names the cause and leave every other failure on the existing generic message. --- .../core/src/main/res/values/strings.xml | 4 +++ .../app/messenger/internal/ChatViewModel.kt | 32 ++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 92f37fa94..1d0072230 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -915,6 +915,10 @@ Your change couldn\'t be saved, so the message is unchanged. Message Not Deleted The message couldn\'t be deleted, so it is still visible to everyone. + Couldn\'t Edit Message + Messages can only be edited for a short time after they\'re sent. + Couldn\'t Delete Message + Messages can only be deleted for a short time after they\'re sent. Message Unknown Contact 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 4ed03a73e..15d72ef46 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 @@ -29,6 +29,8 @@ 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.DeleteMessageError +import com.flipcash.services.models.EditMessageError import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId @@ -785,9 +787,22 @@ internal class ChatViewModel @Inject constructor( chatCoordinator.editMessage(chatId, editing.messageId, text) .onFailure { cause -> trace("failed to edit message - ${cause.localizedMessage}") + // The client gate hides Edit once the window closes, but it cannot + // close the gap between opening the composer inside the window and + // submitting outside it. `CANNOT_EDIT` is the server's answer for + // exactly that race, and it is the one failure here with a cause worth + // naming: nothing went wrong and retrying will not help, which the + // generic "couldn't be saved" copy does not convey. + val expired = cause is EditMessageError.CannotEdit BottomBarManager.showError( - title = resources.getString(R.string.title_messageNotEdited), - message = resources.getString(R.string.description_messageNotEdited), + title = resources.getString( + if (expired) R.string.title_messageEditExpired + else R.string.title_messageNotEdited, + ), + message = resources.getString( + if (expired) R.string.description_messageEditExpired + else R.string.description_messageNotEdited, + ), ) } } @@ -814,9 +829,18 @@ internal class ChatViewModel @Inject constructor( chatCoordinator.deleteMessage(chatId, event.messageId) .onFailure { cause -> trace("failed to delete message - ${cause.localizedMessage}") + // Same race as the edit path: the sheet can be sitting open + // when the delete window closes. `CANNOT_DELETE` names it. + val expired = cause is DeleteMessageError.CannotDelete BottomBarManager.showError( - title = resources.getString(R.string.title_messageNotDeleted), - message = resources.getString(R.string.description_messageNotDeleted), + title = resources.getString( + if (expired) R.string.title_messageDeleteExpired + else R.string.title_messageNotDeleted, + ), + message = resources.getString( + if (expired) R.string.description_messageDeleteExpired + else R.string.description_messageNotDeleted, + ), ) } } From 3139771934d8c586100d50a5c7530c7b34b9429d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 16:54:37 -0400 Subject: [PATCH 3/7] fix(chat): take the keyboard down when an edit is confirmed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirming an edit left the composer focused with the IME up, so the keyboard stayed over a field that was no longer editing anything — and the draft finishEditing restores would have pulled it straight back even if it had been hidden. Route the confirm through keyboard.hideIfVisible, which clears focus before hiding, matching what the send-cash control already does. Also drops the trailing period from the generic edit-failure subtitle. --- apps/flipcash/core/src/main/res/values/strings.xml | 2 +- .../internal/screens/components/ChatBottomBar.kt | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 1d0072230..b3ca329a5 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -912,7 +912,7 @@ Delete message? This can\'t be undone Message Not Edited - Your change couldn\'t be saved, so the message is unchanged. + Your change couldn\'t be saved, so the message is unchanged Message Not Deleted The message couldn\'t be deleted, so it is still visible to everyone. Couldn\'t Edit Message diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index d9647319d..32f64e517 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -202,8 +202,13 @@ internal fun UserControlBottomBar( // does, so the composer cannot show a checkmark and send a new message. submit = if (state.editing != null) { ChatInputSubmit.ConfirmEdit { - dispatch(ChatViewModel.Event.SubmitEdit) - keyboard.restartInput() + // Confirming ends the edit, so the composer stops being an + // open field: take the keyboard down first, which also clears + // focus so it does not come straight back onto the draft + // `finishEditing` restores into the same input. + keyboard.hideIfVisible { + dispatch(ChatViewModel.Event.SubmitEdit) + } } } else { ChatInputSubmit.Send { From f5ef0e87ad9cb0e32d734eb1093f02868317cc3e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 16:59:12 -0400 Subject: [PATCH 4/7] style(chat): name the perform argument at the ChatInputSubmit call sites ConfirmEdit { ... } read as an object declaration with a body rather than a data class taking one lambda. Naming the argument says which it is. --- .../screens/components/ChatBottomBar.kt | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 32f64e517..b773b75dc 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -200,21 +200,21 @@ internal fun UserControlBottomBar( state = state.chatInputState, // One read of the edit state decides both the glyph and what the tap // does, so the composer cannot show a checkmark and send a new message. + // + // Confirming ends the edit, so the composer stops being an open field: + // hiding the keyboard clears focus too, so it does not come straight + // back onto the draft `finishEditing` restores into the same input. submit = if (state.editing != null) { - ChatInputSubmit.ConfirmEdit { - // Confirming ends the edit, so the composer stops being an - // open field: take the keyboard down first, which also clears - // focus so it does not come straight back onto the draft - // `finishEditing` restores into the same input. - keyboard.hideIfVisible { - dispatch(ChatViewModel.Event.SubmitEdit) - } - } + ChatInputSubmit.ConfirmEdit( + perform = { keyboard.hideIfVisible { dispatch(ChatViewModel.Event.SubmitEdit) } }, + ) } else { - ChatInputSubmit.Send { - dispatch(ChatViewModel.Event.SendMessage) - keyboard.restartInput() - } + ChatInputSubmit.Send( + perform = { + dispatch(ChatViewModel.Event.SendMessage) + keyboard.restartInput() + }, + ) }, ) From c95705391eff8c1997bf05ea62f0cc4f119cc515 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 17:08:21 -0400 Subject: [PATCH 5/7] refactor(chat): make ChatInputSubmit one value with a mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sealed interface read as two objects at the call site — `ChatInputSubmit.ConfirmEdit { ... }` looks like it names an object, not a constructor. A single data class carrying a `Mode` reads as construction and keeps the guarantee the sealed shape existed for: the glyph and the action still travel together, so a caller cannot put up a checkmark that sends a new message. The glyph animates on `submit.mode` now, which drops the `contentKey = { it::class }` the lambda field made necessary, and the icon branch becomes an exhaustive `when` rather than an `if/else` that treated anything not `ConfirmEdit` as Send. Test tags are unchanged; `chat_send_icon` is read by the baseline profile generator. --- .../screens/components/ChatBottomBar.kt | 6 ++- .../getcode/ui/components/chat/ChatInput.kt | 38 ++++++++++--------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index b773b75dc..dbdaac831 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -205,11 +205,13 @@ internal fun UserControlBottomBar( // hiding the keyboard clears focus too, so it does not come straight // back onto the draft `finishEditing` restores into the same input. submit = if (state.editing != null) { - ChatInputSubmit.ConfirmEdit( + ChatInputSubmit( + mode = ChatInputSubmit.Mode.ConfirmEdit, perform = { keyboard.hideIfVisible { dispatch(ChatViewModel.Event.SubmitEdit) } }, ) } else { - ChatInputSubmit.Send( + ChatInputSubmit( + mode = ChatInputSubmit.Mode.Send, perform = { dispatch(ChatViewModel.Event.SendMessage) keyboard.restartInput() diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt index 7a086384a..a669a3f05 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt @@ -55,14 +55,17 @@ import com.getcode.ui.components.TextInput * means a caller cannot put up a checkmark that sends a new message, and leaves it reading its own * edit state once rather than once per parameter. */ -sealed interface ChatInputSubmit { - val perform: () -> Unit - - /** Sends the field's text as a new message. */ - data class Send(override val perform: () -> Unit) : ChatInputSubmit +data class ChatInputSubmit( + val mode: Mode, + val perform: () -> Unit, +) { + enum class Mode { + /** Sends the field's text as a new message. */ + Send, - /** Confirms an edit of a message already in the transcript. */ - data class ConfirmEdit(override val perform: () -> Unit) : ChatInputSubmit + /** Confirms an edit of a message already in the transcript. */ + ConfirmEdit, + } } @Composable @@ -146,18 +149,17 @@ fun ChatInput( // mode reads as the same control changing meaning rather than two controls // swapping places. AnimatedContent( - targetState = submit, - // Keyed by kind, not by value: each submit carries a fresh lambda, so - // equality alone would restart the crossfade on every recomposition. - contentKey = { it::class }, + // The mode, not the whole submit: each one carries a fresh lambda, so + // animating on the value would restart the crossfade every recomposition. + targetState = submit.mode, transitionSpec = { (fadeIn(sendSpec) + scaleIn(sendSpec, initialScale = 0.6f)) togetherWith (fadeOut(sendSpec) + scaleOut(sendSpec, targetScale = 0.6f)) }, label = "send glyph", ) { target -> - if (target is ChatInputSubmit.ConfirmEdit) { - Icon( + when (target) { + ChatInputSubmit.Mode.ConfirmEdit -> Icon( modifier = Modifier .testTag("chat_confirm_edit_icon") .size(CodeTheme.dimens.staticGrid.x5), @@ -165,8 +167,8 @@ fun ChatInput( tint = Color.Black, contentDescription = "Confirm edit", ) - } else { - Icon( + + ChatInputSubmit.Mode.Send -> Icon( modifier = Modifier .testTag("chat_send_icon") .size(CodeTheme.dimens.staticGrid.x5), @@ -189,7 +191,7 @@ private fun Preview_ChatInput_Empty() { Box(modifier = Modifier.background(Color(0xFF19191A))) { ChatInput( modifier = Modifier.padding(15.dp), - submit = ChatInputSubmit.Send {}, + submit = ChatInputSubmit(ChatInputSubmit.Mode.Send) {}, ) } } @@ -202,7 +204,7 @@ private fun Preview_ChatInput_Typing() { Box(modifier = Modifier.background(Color(0xFF19191A))) { ChatInput( modifier = Modifier.padding(15.dp), - submit = ChatInputSubmit.Send {}, + submit = ChatInputSubmit(ChatInputSubmit.Mode.Send) {}, state = TextFieldState("That’s very kind of you. I ha") ) } @@ -216,7 +218,7 @@ private fun Preview_ChatInput_Editing() { Box(modifier = Modifier.background(Color(0xFF19191A))) { ChatInput( modifier = Modifier.padding(15.dp), - submit = ChatInputSubmit.ConfirmEdit {}, + submit = ChatInputSubmit(ChatInputSubmit.Mode.ConfirmEdit) {}, state = TextFieldState("That’s very kind of you. I have") ) } From 8354f7e24be7a3c5596a1fce3ec90a13cce3648a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 17:10:20 -0400 Subject: [PATCH 6/7] style(chat): drop the trailing period from the delete failure subtitle Matches the edit failure subtitle next to it. --- apps/flipcash/core/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index b3ca329a5..374be36a2 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -914,7 +914,7 @@ Message Not Edited Your change couldn\'t be saved, so the message is unchanged Message Not Deleted - The message couldn\'t be deleted, so it is still visible to everyone. + The message couldn\'t be deleted, so it is still visible to everyone Couldn\'t Edit Message Messages can only be edited for a short time after they\'re sent. Couldn\'t Delete Message From dffab4248833960175248204f317bc126f586ba4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 17:11:19 -0400 Subject: [PATCH 7/7] style(chat): drop the trailing periods from the expired-window subtitles All four message failure subtitles end without one now. --- apps/flipcash/core/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 374be36a2..648ae0b13 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -916,9 +916,9 @@ Message Not Deleted The message couldn\'t be deleted, so it is still visible to everyone Couldn\'t Edit Message - Messages can only be edited for a short time after they\'re sent. + Messages can only be edited for a short time after they\'re sent Couldn\'t Delete Message - Messages can only be deleted for a short time after they\'re sent. + Messages can only be deleted for a short time after they\'re sent Message Unknown Contact