diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift index 87ad94818..9dd260aef 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift @@ -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 @@ -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( diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift index ab402e675..9d9677ccd 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift @@ -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) diff --git a/docs/superpowers/specs/2026-09-01-chat-message-actions-design.md b/docs/superpowers/specs/2026-09-01-chat-message-actions-design.md index 7639b87b9..0877756c9 100644 --- a/docs/superpowers/specs/2026-09-01-chat-message-actions-design.md +++ b/docs/superpowers/specs/2026-09-01-chat-message-actions-design.md @@ -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