feat(chat): gate Edit and Delete on the server's message windows - #1399
Merged
bmc08gt merged 7 commits intoSep 3, 2026
Merged
Conversation
`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.
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.
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.
ConfirmEdit { ... } read as an object declaration with a body rather than a
data class taking one lambda. Naming the argument says which it is.
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.
Matches the edit failure subtitle next to it.
All four message failure subtitles end without one now.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #1397, which added the two windows to the domain layer and pinned
flipcash2-client-protocol0.4.0. Nothing read them; this reads them.What changes
MessagePolicygainsdeleteWindownext toeditWindow, andMessageCapability.Deleteis gated on it the wayEditalready was.Deletewas previously added unconditionally to any message from self.MessagePolicy.from(editWindow, deleteWindow)substitutes 15 minutes / 48 hours for either window the server leaves unset. Both are named constants on the companion (FallbackEditWindow,FallbackDeleteWindow) rather than inline literals, so the values are greppable and comparable against iOS.The fallback sits at policy construction, not in
UserFlagsMapperorUserFlags— the mapper'snullkeeps meaning "the server said nothing", so the distinction stays visible to anything else reading the flags. That placement also covers the failed-fetch case without a second branch:UserFlagsCoordinator.resolvedFlagsfalls back toUserFlags.Defaultwhenever the server flags are absent, andUserFlags.Defaultcarriesnullfor both windows, so a failed fetch arrives as the samenullan unset field would. There is a comment onChatViewModel.messagePolicyrecording that.MessagePolicy.Defaultis now the fallback policy rather than an unbounded one, so a call site that forgets to pass a policy is gated instead of open.nullstill means no limit, butfromnever produces one.The documented decision this inverts
The old KDoc said the server does not publish a window, so edit is left open and
CANNOT_EDITremains the authority. With a fallback in force that is no longer true: a server that sends nothing now gets the client's window imposed on it, and an edit the server would have accepted at 20 minutes is hidden at 15. The KDoc is rewritten to state the new rule and that cost rather than leaving the old reasoning contradicting the code. The inversion is only in what the client offers — the server remains the authority for everything it does offer.Expiry while the transcript is on screen
Capabilities are resolved inside a paging map, so a message resolved at send time kept
Edit/Deleteforever: a window lapsing produces no re-emission. At a 15-minute default that is an ordinary session, not a theoretical case.A 30-second poll (
capabilityClock) re-runs the mapping. It is cheap because it sits belowcachedIn— nothing re-fetches, which is the same property the existingpendingMutationsoverlay relies on — and the token metadata the mapping enriches with is memory-cached. Chosen over a timer armed at each message's expiry because the transcript is paged, so the set of loaded messages and the next expiry both move as the user scrolls.The cost is up to 30 seconds of staleness at each boundary, during which the server answers
CANNOT_EDIT/CANNOT_DELETE. That is the race the gating cannot close anyway — a menu resolved a moment before expiry is stale by the time it is tapped at any interval. This covers the transcript, not an already-open selection bar:State.selectionholds the bubble captured at long-press and keeps the capabilities it was captured with. Selection is a few seconds of attention rather than a row parked on screen, so it is left to the server error. The trade-offs are recorded in comments oncapabilityClockandCapabilityRefreshInterval.When the window closes mid-action
Nothing closes 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 showed the generic "your change couldn't be saved" copy — accurate, and unhelpful, since nothing went wrong and retrying will not help.Those two results now get copy naming the cause:
Every other edit or delete failure keeps the existing generic message.
Parity with iOS
Same fallback values, same treatment of unset windows, same inclusive
<=at exactly the window length, both windows measured from send time rather than last edit. No deviations. The testthe fallback windows are what iOS appliesasserts the two constants directly, so a change on one platform fails a test rather than silently splitting the capability sets.iOS carries the same two strings.
Out of scope
No change to the server-side authority model, or to how
ChatMessagingServicemapsCANNOT_EDIT/CANNOT_DELETE— only to howChatViewModelpresents them.