Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@ public struct MessagePolicy: Hashable, Sendable {
/// reversal: the previous rule defaulted to `nil` on the grounds that a client-side window
/// would only hide an action the server would have accepted. It can now do exactly that — a
/// message past the fallback loses Edit even where the server would have taken the request.
/// We accept that because an affordance the server *will* reject is the worse failure, and
/// because the fallback matches Android's, so the two clients offer the same rows for the
/// same message.
/// We accept that because an affordance the server *will* reject is the worse failure.
public let editWindow: TimeInterval?

/// How long after sending a message stays deletable, or `nil` for no limit. Same source and
Expand All @@ -39,12 +37,22 @@ public struct MessagePolicy: Hashable, Sendable {

public let deletedPresentation: DeletedMessagePresentation

/// The window applied when the server sends no edit window. Kept in step with Android's
/// constant of the same value so both clients gate identically.
/// The window applied when the server sends no edit window.
///
/// Maintained in parallel with Android `MessagePolicy.FallbackEditWindow`
/// (`apps/flipcash/shared/chat/.../MessageCapability.kt`). The two must move together or the
/// clients offer different rows for the same message; nothing enforces it, so changing one
/// means changing the other in the same release. An earlier version of this comment claimed
/// the match already held — Android had no such constant until it was added to settle this.
///
/// The value is a product choice, not a figure the contract supplies: `message_edit_window`
/// documents what it means but never what an absent field implies. Replace it the moment the
/// server does specify one.
public static let fallbackEditWindow: TimeInterval = 900 // 15 minutes

/// The window applied when the server sends no delete window. Kept in step with Android's
/// constant of the same value so both clients gate identically.
/// The window applied when the server sends no delete window. Same parallel-maintenance duty
/// and same provenance as ``fallbackEditWindow``; Android holds it as
/// `MessagePolicy.FallbackDeleteWindow`.
public static let fallbackDeleteWindow: TimeInterval = 172_800 // 48 hours

public init(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ struct MessageCapabilityTests {

@Test("Flags that carry no windows fall back to 15 minutes and 48 hours")
func unsetFlagsFallBackToTheAgreedWindows() {
// Both numbers are maintained by hand against Android `MessagePolicy.FallbackEditWindow` /
// `FallbackDeleteWindow`. Nothing checks the two repos against each other, so this pins the
// iOS side: a change here fails until someone states the new value, which is the prompt to
// go and change Android too.
let policy = MessagePolicy(userFlags: nil)
#expect(policy.editWindow == 900)
#expect(policy.deleteWindow == 172_800)
Expand Down
21 changes: 15 additions & 6 deletions docs/superpowers/specs/2026-09-01-chat-message-actions-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,21 @@ itself has already happened, which invites someone to delete a payment out of th
transcript and leave the money moved. Today these rows return no menu at all
(`ChatViewController.swift:519`); they will return a Reply-only menu.

**The edit window is a policy value, defaulting to none.** WhatsApp cuts editing off
after fifteen minutes. Our contract has `CANNOT_EDIT` but never documents what triggers
it, so a client-side window would be a guess: too long and we offer an action that
fails, too short and we hide one that would have worked. `MessagePolicy` carries an
optional `editWindow` that is `nil` today. If the backend documents a window it becomes a
constant.
**The edit window is a policy value.** WhatsApp cuts editing off after fifteen minutes.
Our contract has `CANNOT_EDIT` but never documents what triggers it, so a client-side
window is a guess: too long and we offer an action that fails, too short and we hide one
that would have worked. `MessagePolicy` carries an optional `editWindow`.

*Superseded (2026-09-18).* This section originally said the window defaults to none, and
that a constant waits on the backend documenting one. `UserFlags` now carries
`message_edit_window` and `message_delete_window`, and where the server sends neither,
both clients substitute 15 minutes and 48 hours rather than leaving the action open — the
rejected affordance is judged the worse of the two failures. The constants are
`MessagePolicy.fallbackEditWindow` / `fallbackDeleteWindow` here and
`MessagePolicy.FallbackEditWindow` / `FallbackDeleteWindow` on Android, maintained in
parallel with nothing enforcing the match. They remain a product choice rather than a
figure the contract supplies, and no observation of a non-staff account on a current
build exists yet to say how often the fallback is what a user actually gets.

**Reply is started from the context menu and from a swipe.** The swipe is the gesture
people actually reach for. Its cost is a pan recognizer that has to coexist with the
Expand Down
Loading