Skip to content

feat(chat): gate Edit and Delete on the server's message windows - #1399

Merged
bmc08gt merged 7 commits into
feat/user-flags-message-windowsfrom
feat/chat-message-window-gating
Sep 3, 2026
Merged

feat(chat): gate Edit and Delete on the server's message windows#1399
bmc08gt merged 7 commits into
feat/user-flags-message-windowsfrom
feat/chat-message-window-gating

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #1397, which added the two windows to the domain layer and pinned flipcash2-client-protocol 0.4.0. Nothing read them; this reads them.

What changes

MessagePolicy gains deleteWindow next to editWindow, and MessageCapability.Delete is gated on it the way Edit already was. Delete was 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 UserFlagsMapper or UserFlags — the mapper's null keeps 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.resolvedFlags falls back to UserFlags.Default whenever the server flags are absent, and UserFlags.Default carries null for both windows, so a failed fetch arrives as the same null an unset field would. There is a comment on ChatViewModel.messagePolicy recording that.

MessagePolicy.Default is now the fallback policy rather than an unbounded one, so a call site that forgets to pass a policy is gated instead of open. null still means no limit, but from never 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_EDIT remains 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/Delete forever: 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 below cachedIn — nothing re-fetches, which is the same property the existing pendingMutations overlay 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.selection holds 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 on capabilityClock and CapabilityRefreshInterval.

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:

Title Subtitle
Edit expired Couldn't Edit Message Messages can only be edited for a short time after they're sent.
Delete expired Couldn't Delete Message Messages can only be deleted for a short time after they're sent.

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 test the fallback windows are what iOS applies asserts 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 ChatMessagingService maps CANNOT_EDIT / CANNOT_DELETE — only to how ChatViewModel presents them.

`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.
@bmc08gt bmc08gt self-assigned this Sep 3, 2026
@github-actions github-actions Bot added the type: feature New functionality label Sep 3, 2026
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.
@github-actions github-actions Bot added the area: ui Compose UI, theme, components, resources label Sep 3, 2026
Matches the edit failure subtitle next to it.
All four message failure subtitles end without one now.
@bmc08gt
bmc08gt merged commit 3c99d70 into feat/user-flags-message-windows Sep 3, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: ui Compose UI, theme, components, resources type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant