Skip to content

feat(chat): gate edit and delete on the server's message windows - #724

Merged
bmc08gt merged 5 commits into
mainfrom
feat/user-flags-message-windows
Sep 3, 2026
Merged

feat(chat): gate edit and delete on the server's message windows#724
bmc08gt merged 5 commits into
mainfrom
feat/user-flags-message-windows

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Brings the message-window work onto main. #722
merged into this branch rather than into main, so none of it has landed yet.

UserFlags now carries messageEditWindow and messageDeleteWindow, and MessagePolicy turns
them into the two gates. MessageCapability.resolve withholds Edit and Delete once a message is
past its window; nextExpiry returns the soonest deadline still ahead, and
ConversationLoadCoordinator wakes at deadline + 1s to re-map the transcript so a row loses its
actions on time instead of at the next unrelated redraw.

Both windows fall back to 15 minutes and 48 hours when the server sends nothing. That is a
deliberate reversal of the earlier rule, which left the action open on the grounds that a
client-side window could only hide something the server would have accepted. It can now do exactly
that — but an affordance the server will reject is the worse failure, and the fallbacks match
Android's, so the two clients offer the same rows for the same message.

The window boundary is inclusive on both sides: resolve grants the capability at exactly the
window's length, and nextExpiry keeps a deadline landing exactly on now. Splitting them would
leave a row actionable with no timer armed to take it away. now is injected into both, so
deadlineExactlyAtNowIsScheduled pins that instant rather than leaving it to Date's resolution.

The proto pins move with it — ocp-client-protocol to 0.3.0 and flipcash2-client-protocol to
0.4.0 — since hasMessageEditWindow comes from the latter.

Two notes for review:

  • The merge base predates #721, so the
    diff still shows the UserFlags fields that are already on main. The merge itself is clean.
  • The branch also predates #718 and
    #723, so it is still pinned to
    shared-core 0.3.1 against main's 0.4.1. Merging main in picks that up.

UserFlags gained two message-typed Duration fields (17, 18) upstream:
message_edit_window and message_delete_window. Map them onto UserFlags
as optional TimeIntervals, gated on hasMessageEditWindow /
hasMessageDeleteWindow so an absent window is not read as zero,
matching the existing billExchangeDataTimeout convention.

Scaffolding only, nothing reads these yet. A follow-up wires them into
MessagePolicy for the chat edit/delete affordances.
Picks up messageEditWindow and messageDeleteWindow on UserFlags. Blocked until
0.4.0 is published; ocp-client-protocol is unaffected and stays at 0.2.0.
b5432cc moved the pin in FlipcashAPI/Package.swift to 0.4.0 but left the
workspace Package.resolved resolving 0.2.0, so the manifest and the lockfile
disagreed and the next build to touch the workspace rewrote it. CLAUDE.md
requires the workspace Package.resolved be committed.

The revision matches the 0.4.0 tag (27e3f09a). ocp-client-protocol is untouched
and stays at 0.2.0, which its pin and lockfile entry already agree on.
Keeps iOS on the same package version as Android. 0.3.0 over 0.2.0 is
Android-only content — R8 keep rules for the generated messages, plus CHANGELOG
and README. No .proto and no Swift changed, so this carries no contract change
and nothing in FlipcashAPI moves.

Pin and workspace lockfile updated together; the revision matches the 0.3.0 tag
(7c37ecc0). Verified with Scripts/build.sh, which resolved 0.3.0 and left the
lockfile entry as written.
* feat(chat): gate edit and delete on the server's message windows

The transcript offered Edit and Delete on every confirmed message of your own.
`UserFlags` has carried `messageEditWindow` and `messageDeleteWindow` since the
parent branch, but nothing read them: `ConversationLoadCoordinator` passed
`MessagePolicy.default`, which set no windows at all.

`MessagePolicy` now takes a `deleteWindow` alongside `editWindow` and is built
from the flags at the call site. Where the server sends nothing, it falls back to
15 minutes for edit and 48 hours for delete, as `fallbackEditWindow` and
`fallbackDeleteWindow`. `Session.userFlags` is optional and a failed fetch never
assigns, so optional-chaining collapses absent flags, a cached row, and an unset
field into that one fallback — there is no separate failure path.

That inverts the old rule, and the doc comment says so: the client can now hide
an edit the server would have accepted. An affordance the server rejects is the
worse failure, and Android applies the same two values, so both clients offer
the same rows.

Resolving against a real clock is the part `map` was written to avoid. Its
comment recorded the constraint — `now: message.date` made elapsed time zero, so
a window could never lapse, and reading `Date.now` inside `map` would cost the
`Inputs` equality short-circuit that keeps an unrelated tick free. `now` is now
carried in `Inputs` and advanced only by `scheduleWindowExpiry`, which asks
`MessageCapability.nextExpiry` for the soonest deadline in the window and sleeps
until it. Nothing polls: with no expiring message there is no timer, and each
firing schedules only the next one. Waking a second past the deadline avoids
re-granting at the inclusive boundary; a one-hour clamp keeps a 48-hour delete
window from parking a task behind a transcript nobody is reading.

The boundary is `<=`, matching Android — a message at exactly the window length
is still actionable.

* fix(chat): keep the deadline that lands exactly on now

`isWithin` grants a capability at exactly the window's length, but `nextExpiry`
dropped a deadline equal to `now`. At that instant the row still offers Edit or
Delete with no timer armed to take it away, so it stays offered until an
unrelated re-map runs.

`now` is an injected parameter in both functions, so this is reachable from a
test rather than only from `Date`'s resolution — the new case pins it.

`>=` cannot re-arm on itself: `ConversationLoadCoordinator` wakes at
`deadline + expiryGrace`, a second past the instant it just scheduled for.
@bmc08gt bmc08gt self-assigned this Sep 3, 2026
@bmc08gt
bmc08gt merged commit 4847f1e into main Sep 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant