From ba8bcbbf58ea5650c29479bf81e5419c845b27dd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 14:37:01 -0400 Subject: [PATCH 1/5] feat(account): carry message edit/delete windows on UserFlags 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. --- .../Sources/FlipcashCore/Models/UserFlags.swift | 12 +++++++++++- FlipcashTests/Database/Database+ProfileTests.swift | 8 ++++++-- FlipcashTests/SessionTests.swift | 4 +++- FlipcashTests/TestSupport/UserFlags+Fixtures.swift | 4 +++- .../TestSupport/WithdrawViewModel+TestSupport.swift | 8 ++++++-- FlipcashTests/WithdrawViewModelTests.swift | 4 +++- 6 files changed, 32 insertions(+), 8 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/UserFlags.swift b/FlipcashCore/Sources/FlipcashCore/Models/UserFlags.swift index a0f2db378..987c969ef 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/UserFlags.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/UserFlags.swift @@ -46,6 +46,14 @@ public struct UserFlags: Codable, Sendable { /// Server-defined tip amounts per fiat currency, in major units. public let tipPresets: [TipPresets] + /// Duration after message creation when a message can be edited, or `nil` when the + /// server did not send a window. + public let messageEditWindow: TimeInterval? + + /// Duration after message creation when a message can be deleted, or `nil` when the + /// server did not send a window. + public let messageDeleteWindow: TimeInterval? + /// Returns the tip presets for a currency, falling back to the USD row — /// mirroring the server's minimum-enforcement fallback — or `nil` when the /// server provided no presets at all. @@ -147,7 +155,9 @@ extension UserFlags { enablePhoneNumberSend: proto.enablePhoneNumberSend, requireCoinbaseEmailVerification: proto.requireCoinbaseEmailVerification, preferredOnrampUsdcLiquidityPool: UsdcLiquidityPool(proto.preferredOnRampUsdcLiquidityPool), - tipPresets: proto.tipPresets.compactMap { TipPresets($0) } + tipPresets: proto.tipPresets.compactMap { TipPresets($0) }, + messageEditWindow: proto.hasMessageEditWindow ? TimeInterval(proto.messageEditWindow.seconds) : nil, + messageDeleteWindow: proto.hasMessageDeleteWindow ? TimeInterval(proto.messageDeleteWindow.seconds) : nil ) } } diff --git a/FlipcashTests/Database/Database+ProfileTests.swift b/FlipcashTests/Database/Database+ProfileTests.swift index c586565bb..e10a4c311 100644 --- a/FlipcashTests/Database/Database+ProfileTests.swift +++ b/FlipcashTests/Database/Database+ProfileTests.swift @@ -29,7 +29,9 @@ struct DatabaseProfileTests { tipPresets: [ UserFlags.TipPresets(currency: .usd, minimum: 1, low: 5, medium: 10, high: 20), UserFlags.TipPresets(currency: .cad, minimum: 2, low: 5, medium: 10, high: 25), - ] + ], + messageEditWindow: 900, + messageDeleteWindow: 300 ) /// Restricted account: no onramp providers, unset timeout, zero fees. @@ -48,7 +50,9 @@ struct DatabaseProfileTests { enablePhoneNumberSend: false, requireCoinbaseEmailVerification: false, preferredOnrampUsdcLiquidityPool: .unknown, - tipPresets: [] + tipPresets: [], + messageEditWindow: nil, + messageDeleteWindow: nil ) // MARK: - Empty (fresh install) - diff --git a/FlipcashTests/SessionTests.swift b/FlipcashTests/SessionTests.swift index 6eb6d69c0..2871661a8 100644 --- a/FlipcashTests/SessionTests.swift +++ b/FlipcashTests/SessionTests.swift @@ -707,7 +707,9 @@ struct SessionOfflineCacheTests { enablePhoneNumberSend: false, requireCoinbaseEmailVerification: false, preferredOnrampUsdcLiquidityPool: .unknown, - tipPresets: [] + tipPresets: [], + messageEditWindow: nil, + messageDeleteWindow: nil ) } diff --git a/FlipcashTests/TestSupport/UserFlags+Fixtures.swift b/FlipcashTests/TestSupport/UserFlags+Fixtures.swift index 0c0b7daaa..900255913 100644 --- a/FlipcashTests/TestSupport/UserFlags+Fixtures.swift +++ b/FlipcashTests/TestSupport/UserFlags+Fixtures.swift @@ -28,7 +28,9 @@ extension UserFlags { enablePhoneNumberSend: false, requireCoinbaseEmailVerification: requireCoinbaseEmailVerification, preferredOnrampUsdcLiquidityPool: .unknown, - tipPresets: tipPresets + tipPresets: tipPresets, + messageEditWindow: nil, + messageDeleteWindow: nil ) } } diff --git a/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift b/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift index 145ab5551..39345f207 100644 --- a/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift +++ b/FlipcashTests/TestSupport/WithdrawViewModel+TestSupport.swift @@ -44,7 +44,9 @@ enum WithdrawViewModelTestHelpers { enablePhoneNumberSend: false, requireCoinbaseEmailVerification: false, preferredOnrampUsdcLiquidityPool: .unknown, - tipPresets: [] + tipPresets: [], + messageEditWindow: nil, + messageDeleteWindow: nil ) return WithdrawViewModel( @@ -139,7 +141,9 @@ enum WithdrawViewModelTestHelpers { enablePhoneNumberSend: false, requireCoinbaseEmailVerification: false, preferredOnrampUsdcLiquidityPool: .unknown, - tipPresets: [] + tipPresets: [], + messageEditWindow: nil, + messageDeleteWindow: nil ) } let stored = try #require(container.session.balance(for: .usdf)) diff --git a/FlipcashTests/WithdrawViewModelTests.swift b/FlipcashTests/WithdrawViewModelTests.swift index c666a0af3..5cf92f135 100644 --- a/FlipcashTests/WithdrawViewModelTests.swift +++ b/FlipcashTests/WithdrawViewModelTests.swift @@ -332,7 +332,9 @@ struct WithdrawViewModelTests { enablePhoneNumberSend: false, requireCoinbaseEmailVerification: false, preferredOnrampUsdcLiquidityPool: .unknown, - tipPresets: [] + tipPresets: [], + messageEditWindow: nil, + messageDeleteWindow: nil ) } let stored = try #require(container.session.balance(for: mint)) From b5432cca2993286ccaf5781cf5f4449cb3b29cdc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 14:38:12 -0400 Subject: [PATCH 2/5] build: pin flipcash2-client-protocol 0.4.0 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. --- FlipcashAPI/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index b9d2ebbc5..374ab6d1e 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -35,7 +35,7 @@ let contractDependencies: [Package.Dependency] = protoLocalRoot.map { root in ] } ?? [ .package(url: "https://github.com/code-payments/ocp-client-protocol", exact: "0.2.0"), - .package(url: "https://github.com/code-payments/flipcash2-client-protocol", exact: "0.2.0"), + .package(url: "https://github.com/code-payments/flipcash2-client-protocol", exact: "0.4.0"), ] let package = Package( From b6295feb8707c4034d44133337eb70a2ffa9f8e4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:41:47 -0400 Subject: [PATCH 3/5] build: resolve flipcash2-client-protocol 0.4.0 in the workspace lockfile b5432cca 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. --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9d08e0d1b..c4cfc85ae 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -86,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash2-client-protocol", "state" : { - "revision" : "9f6e3805672e955863dc91302bc60e27b7f03977", - "version" : "0.2.0" + "revision" : "27e3f09a82f6fbd41c03026ee738f943f4ed465e", + "version" : "0.4.0" } }, { From 063077abbe584cbb0e1d5f1b1cad263ae61dc8c8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:45:49 -0400 Subject: [PATCH 4/5] build: bump ocp-client-protocol to 0.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- FlipcashAPI/Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c4cfc85ae..5b6a5f3ee 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -212,8 +212,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/ocp-client-protocol", "state" : { - "revision" : "b08adb951fcaaa463da21a4dfaa775577254d052", - "version" : "0.2.0" + "revision" : "7c37ecc098d682ccf7ac95e5bd48ef770dbc7962", + "version" : "0.3.0" } }, { diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 374ab6d1e..9babd2a7a 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -34,7 +34,7 @@ let contractDependencies: [Package.Dependency] = protoLocalRoot.map { root in .package(path: "\(root)/flipcash2-client-protocol"), ] } ?? [ - .package(url: "https://github.com/code-payments/ocp-client-protocol", exact: "0.2.0"), + .package(url: "https://github.com/code-payments/ocp-client-protocol", exact: "0.3.0"), .package(url: "https://github.com/code-payments/flipcash2-client-protocol", exact: "0.4.0"), ] From a28dc4e31b04b19c9fc22bffbc8626c02b0f1a02 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 17:13:00 -0400 Subject: [PATCH 5/5] feat(chat): gate edit and delete on the server's message windows (#722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../ConversationLoadCoordinator.swift | 64 ++++++- .../Conversation/MessageCapability.swift | 51 +++++- .../Models/Conversation/MessagePolicy.swift | 65 ++++++- .../MessageCapabilityTests.swift | 159 +++++++++++++++++- 4 files changed, 312 insertions(+), 27 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift index 1b60320af..c79c27160 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift @@ -30,6 +30,21 @@ final class ConversationLoadCoordinator { @ObservationIgnored private var lastInputs: Inputs? @ObservationIgnored private var mapTask: Task? + /// The clock capability resolution reads. It advances only when a window actually lapses, never + /// on every observation tick — see ``scheduleWindowExpiry(for:)``. + @ObservationIgnored private var capabilityClock: Date = .now + @ObservationIgnored private var expiryTask: Task? + + /// Fire a beat after the deadline, not on it. The window boundary is inclusive, so a timer that + /// landed exactly on `date + window` would still resolve the capability as granted and then + /// compute the same deadline again, and the row would never drop. + private static let expiryGrace: TimeInterval = 1 + + /// The furthest ahead a single sleep is allowed to reach. A 48-hour delete window would + /// otherwise park a task for two days behind a transcript nobody is reading; clamping costs at + /// most one extra remap per hour on a transcript left open that long. + private static let expiryHorizon: TimeInterval = 3600 + init( conversationID: ConversationID, controller: ConversationController, @@ -47,6 +62,7 @@ final class ConversationLoadCoordinator { let initial = currentInputs() self.lastInputs = initial self.items = Self.map(initial) + scheduleWindowExpiry(for: initial) observeInputs() } @@ -61,8 +77,16 @@ final class ConversationLoadCoordinator { } onChange: { [weak self] in Task { @MainActor in self?.observeInputs() } } + refresh(with: inputs) + } + + // Re-maps off the main thread when the inputs actually changed, and re-arms the expiry timer + // for whatever the new set implies. Separate from `observeInputs` because the expiry timer + // drives a re-map too, and it must not install a second observation arm to do it. + private func refresh(with inputs: Inputs) { guard inputs != lastInputs else { return } lastInputs = inputs + scheduleWindowExpiry(for: inputs) mapTask?.cancel() mapTask = Task { [weak self] in let mapped = await Task.detached { Self.map(inputs) }.value @@ -71,6 +95,29 @@ final class ConversationLoadCoordinator { } } + // Wakes once, at the next instant a message loses Edit or Delete, and advances `capabilityClock` + // so the re-map resolves against a real clock. Nothing polls: with no expiring message in the + // window there is no timer at all, and each firing schedules only the next deadline. + private func scheduleWindowExpiry(for inputs: Inputs) { + expiryTask?.cancel() + let now = Date.now + guard let deadline = MessageCapability.nextExpiry( + among: inputs.messages, + in: inputs.conversation, + as: inputs.selfUserID, + policy: inputs.policy, + now: now + ) else { return } + + let wake = min(deadline.addingTimeInterval(Self.expiryGrace), now.addingTimeInterval(Self.expiryHorizon)) + expiryTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(max(0, wake.timeIntervalSince(now)))) + guard !Task.isCancelled, let self else { return } + self.capabilityClock = .now + self.refresh(with: self.currentInputs()) + } + } + private func currentInputs() -> Inputs { let conversation = controller.conversation(withID: conversationID) let read = conversation?.counterpartReadReceipt(excluding: controller.selfUserID) @@ -94,7 +141,9 @@ final class ConversationLoadCoordinator { profileCard: loader.isEntireHistory(windowCount: window.count) ? profileCard() : nil, branding: branding, conversation: conversation, - policy: .default + // Read live, so the windows take effect on the same re-map that lands the flags fetch. + policy: MessagePolicy(userFlags: session.userFlags), + now: capabilityClock ) } @@ -109,17 +158,18 @@ final class ConversationLoadCoordinator { return (branding.token, branding.iconURL) }, deletedPresentation: inputs.policy.deletedPresentation, - // `now: message.date` is deliberate: the default policy has no edit window, so `now` is - // unread, and passing the message's own date keeps `map` a pure function of `Inputs`. - // Introducing an edit window makes this a real clock and costs `map` its purity — that - // change needs a re-map trigger, not just a different argument here. + // `now` is carried in `Inputs` rather than read here, which keeps `map` pure and keeps + // the equality short-circuit meaningful: an unrelated tick sees the same clock and does + // no work. The price is that the clock is only as fresh as whatever last advanced it, + // so `scheduleWindowExpiry` owns that — it wakes at each window's expiry, sets the + // clock, and re-maps. Between those wakes no capability boundary can have been crossed. capabilities: { message in MessageCapability.resolve( for: message, in: inputs.conversation, as: inputs.selfUserID, policy: inputs.policy, - now: message.date + now: inputs.now ) } ) @@ -146,6 +196,8 @@ final class ConversationLoadCoordinator { var branding: [PublicKey: Branding] var conversation: Conversation? var policy: MessagePolicy + /// The clock capabilities resolve against; advanced only at a window's expiry. + var now: Date struct Branding: Equatable, Sendable { var token: String diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index 78258bced..d26b32ea9 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -78,14 +78,53 @@ extension MessageCapability { return [] } - var capabilities: Set = [.copy, .delete] - if let window = policy.editWindow { - if now.timeIntervalSince(message.date) <= window { - capabilities.insert(.edit) - } - } else { + var capabilities: Set = [.copy] + if isWithin(policy.editWindow, of: message, at: now) { capabilities.insert(.edit) } + if isWithin(policy.deleteWindow, of: message, at: now) { + capabilities.insert(.delete) + } return capabilities } + + /// Whether `message` is still inside `window` at `now`. A `nil` window never lapses. + /// + /// The comparison is `<=`, so a message at exactly the window's length is still actionable. + /// Android's `MessageCapability.kt` uses `<=` at the same boundary; the two must agree. + private static func isWithin(_ window: TimeInterval?, of message: ConversationMessage, at now: Date) -> Bool { + guard let window else { return true } + return now.timeIntervalSince(message.date) <= window + } + + /// The earliest instant after `now` at which some message in `messages` loses a capability, or + /// `nil` when none of them will ever change again. + /// + /// Eligibility runs through ``resolve(for:in:as:policy:now:)`` rather than re-deriving it, so a + /// message that has no windowed capability to lose — someone else's, a tombstone, an + /// unconfirmed send — contributes no deadline and the two stay in step by construction. + public static func nextExpiry( + among messages: [ConversationMessage], + in conversation: Conversation?, + as selfUserID: UserID, + policy: MessagePolicy, + now: Date + ) -> Date? { + var earliest: Date? + for message in messages { + let capabilities = resolve(for: message, in: conversation, as: selfUserID, policy: policy, now: now) + for capability in capabilities { + guard let window = policy.window(for: capability) else { continue } + let expiry = message.date.addingTimeInterval(window) + // `>=`, not `>`: the window boundary is inclusive, so a deadline landing exactly on + // `now` is one the capability is still granted at. Dropping it would leave the row + // actionable with no timer armed to take it away. The caller wakes a beat after the + // deadline rather than on it, so keeping this instant cannot re-arm on itself. + guard expiry >= now else { continue } + if let current = earliest, current <= expiry { continue } + earliest = expiry + } + } + return earliest + } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift index 3cfcd0c76..87ad94818 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift @@ -17,19 +17,72 @@ public enum DeletedMessagePresentation: Hashable, Sendable, Codable { } /// The tunables that govern what may be done to a message and how a deleted one is shown. Group -/// chats will eventually vary these per conversation; today every conversation gets `.default`. +/// chats will eventually vary these per conversation; today every conversation gets the windows +/// the server sends on ``UserFlags``, or ``MessagePolicy/default`` before those have arrived. public struct MessagePolicy: Hashable, Sendable { - /// How long after sending a message stays editable, or `nil` for no limit. The server does not - /// enforce a window today, so the default is `nil` — a client-side window would only hide an - /// action the server would have accepted. + /// How long after sending a message stays editable, or `nil` for no limit. + /// + /// The server sends this on ``UserFlags``; when it sends nothing we fall back to + /// ``fallbackEditWindow`` rather than leaving the action open forever. That is a deliberate + /// 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. public let editWindow: TimeInterval? + + /// How long after sending a message stays deletable, or `nil` for no limit. Same source and + /// same trade-off as ``editWindow``, falling back to ``fallbackDeleteWindow``. + public let deleteWindow: TimeInterval? + public let deletedPresentation: DeletedMessagePresentation - public init(editWindow: TimeInterval?, 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. + 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. + public static let fallbackDeleteWindow: TimeInterval = 172_800 // 48 hours + + public init( + editWindow: TimeInterval?, + deleteWindow: TimeInterval?, + deletedPresentation: DeletedMessagePresentation + ) { self.editWindow = editWindow + self.deleteWindow = deleteWindow self.deletedPresentation = deletedPresentation } - public static let `default` = MessagePolicy(editWindow: nil, deletedPresentation: .placeholder) + /// Builds the policy in force from the server's feature flags, substituting the fallback + /// windows for anything the server left unset. + /// + /// `userFlags` is optional because ``Session/userFlags`` is: it is `nil` until the cached row + /// is restored, and a failed fetch never assigns, so it stays at whatever it was — `nil` with + /// no cached row, otherwise flags whose own window fields may be unset. Optional-chaining + /// collapses all three of those into the same expression, so this one fallback is also the + /// failed-fetch behaviour and there is no second path to keep in step. `nil` on the model keeps + /// meaning "the server said nothing"; the substitution happens only here. + public init(userFlags: UserFlags?, deletedPresentation: DeletedMessagePresentation = .placeholder) { + self.init( + editWindow: userFlags?.messageEditWindow ?? Self.fallbackEditWindow, + deleteWindow: userFlags?.messageDeleteWindow ?? Self.fallbackDeleteWindow, + deletedPresentation: deletedPresentation + ) + } + + /// The window governing `capability`, or `nil` when it never lapses. + public func window(for capability: MessageCapability) -> TimeInterval? { + switch capability { + case .edit: editWindow + case .delete: deleteWindow + case .copy, .reply: nil + } + } + + /// The policy in force before any flags have been read — the fallback windows. + public static let `default` = MessagePolicy(userFlags: nil) } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift index e3c256212..ce0226849 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift @@ -8,6 +8,7 @@ import Testing import Foundation @testable import FlipcashCore +import FlipcashAPI @Suite("Message capabilities") struct MessageCapabilityTests { @@ -62,21 +63,161 @@ struct MessageCapabilityTests { #expect(resolve(cash).isEmpty) } - @Test("With an edit window configured, a message inside it stays editable") - func messageInsideEditWindowIsEditable() { - let policy = MessagePolicy(editWindow: 900, deletedPresentation: .placeholder) + // MARK: - Windows - + + private func windows(edit: TimeInterval?, delete: TimeInterval?) -> MessagePolicy { + MessagePolicy(editWindow: edit, deleteWindow: delete, deletedPresentation: .placeholder) + } + + @Test("A message inside both windows keeps every action") + func messageInsideBothWindowsIsFullyActionable() { + let policy = windows(edit: 900, delete: 172_800) #expect(resolve(text("hi", from: me, sentAgo: 600), policy: policy) == [.copy, .edit, .delete]) } - @Test("With an edit window configured, a message past it can still be deleted but not edited") + @Test("A message past the edit window but inside the delete window can still be deleted") func messagePastEditWindowIsDeleteOnly() { - let policy = MessagePolicy(editWindow: 900, deletedPresentation: .placeholder) + let policy = windows(edit: 900, delete: 172_800) + #expect(resolve(text("hi", from: me, sentAgo: 1_200), policy: policy) == [.copy, .delete]) + } + + @Test("A message past both windows can only be copied") + func messagePastBothWindowsIsCopyOnly() { + let policy = windows(edit: 900, delete: 172_800) + #expect(resolve(text("hi", from: me, sentAgo: 200_000), policy: policy) == [.copy]) + } + + @Test("A message past the delete window loses delete even while it is still editable") + func deleteWindowGatesIndependentlyOfEdit() { + // A delete window shorter than the edit window is not the configuration we ship, but it is + // what proves the two gates are independent rather than one implying the other. + let policy = windows(edit: 900, delete: 60) + #expect(resolve(text("hi", from: me, sentAgo: 300), policy: policy) == [.copy, .edit]) + } + + @Test("At exactly the window length both actions are still offered — the boundary is inclusive") + func boundaryIsInclusive() { + let policy = windows(edit: 900, delete: 172_800) + #expect(resolve(text("hi", from: me, sentAgo: 900), policy: policy) == [.copy, .edit, .delete]) + #expect(resolve(text("hi", from: me, sentAgo: 172_800), policy: policy) == [.copy, .delete]) + } + + @Test("A hair past the boundary the action is gone") + func justPastBoundaryDropsTheAction() { + let policy = windows(edit: 900, delete: 172_800) + #expect(resolve(text("hi", from: me, sentAgo: 900.001), policy: policy) == [.copy, .delete]) + #expect(resolve(text("hi", from: me, sentAgo: 172_800.001), policy: policy) == [.copy]) + } + + @Test("A nil window never lapses") + func nilWindowNeverLapses() { + let policy = windows(edit: nil, delete: nil) + #expect(resolve(text("hi", from: me, sentAgo: 10_000_000), policy: policy) == [.copy, .edit, .delete]) + } + + // MARK: - Fallbacks - + + @Test("Flags that carry no windows fall back to 15 minutes and 48 hours") + func unsetFlagsFallBackToTheAgreedWindows() { + let policy = MessagePolicy(userFlags: nil) + #expect(policy.editWindow == 900) + #expect(policy.deleteWindow == 172_800) + #expect(MessagePolicy.fallbackEditWindow == 900) + #expect(MessagePolicy.fallbackDeleteWindow == 172_800) + } + + @Test("Absent flags — a failed or pending fetch — gate the same as flags with unset windows") + func absentFlagsGateLikeUnsetWindows() { + // `Session.userFlags` is nil until a cached row is restored, and a failed fetch never + // assigns — so this is the no-flags path, and it must not be more permissive than the + // fallback. + let policy = MessagePolicy(userFlags: nil) + #expect(resolve(text("hi", from: me, sentAgo: 600), policy: policy) == [.copy, .edit, .delete]) #expect(resolve(text("hi", from: me, sentAgo: 1_200), policy: policy) == [.copy, .delete]) + #expect(resolve(text("hi", from: me, sentAgo: 200_000), policy: policy) == [.copy]) + } + + @Test("Flags that arrived with both windows unset fall back too") + func presentFlagsWithUnsetWindowsFallBack() { + let flags = UserFlags(Flipcash_Account_V1_UserFlags()) + #expect(flags.messageEditWindow == nil) + #expect(flags.messageDeleteWindow == nil) + + let policy = MessagePolicy(userFlags: flags) + #expect(policy.editWindow == MessagePolicy.fallbackEditWindow) + #expect(policy.deleteWindow == MessagePolicy.fallbackDeleteWindow) + } + + @Test("Windows the server did send are used as-is, not replaced by the fallbacks") + func serverWindowsWin() { + let flags = UserFlags(Flipcash_Account_V1_UserFlags.with { + $0.messageEditWindow = .with { $0.seconds = 60 } + $0.messageDeleteWindow = .with { $0.seconds = 120 } + }) + let policy = MessagePolicy(userFlags: flags) + #expect(policy.editWindow == 60) + #expect(policy.deleteWindow == 120) + #expect(resolve(text("hi", from: me, sentAgo: 90), policy: policy) == [.copy, .delete]) + } + + @Test("The default policy is the fallback policy") + func defaultPolicyCarriesTheFallbacks() { + #expect(MessagePolicy.default.editWindow == MessagePolicy.fallbackEditWindow) + #expect(MessagePolicy.default.deleteWindow == MessagePolicy.fallbackDeleteWindow) + #expect(resolve(text("hi", from: me, sentAgo: 86_400)) == [.copy, .delete]) + } + + // MARK: - Expiry scheduling - + + @Test("The next expiry is the soonest window still ahead of now") + func nextExpiryIsTheSoonestDeadline() { + let policy = windows(edit: 900, delete: 172_800) + let recent = text("recent", from: me, sentAgo: 60) + let older = text("older", from: me, sentAgo: 300) + let expiry = MessageCapability.nextExpiry( + among: [recent, older], in: nil, as: me, policy: policy, now: now + ) + // `older` loses edit first: sent 300s ago, so 600s from now. + #expect(expiry == older.date.addingTimeInterval(900)) + } + + @Test("A deadline landing exactly on now is still scheduled — the capability is granted at that instant") + func deadlineExactlyAtNowIsScheduled() { + // `resolve` grants edit at exactly the window's length, so the deadline that takes it away + // has to survive `nextExpiry` too; dropping it would leave Edit on the row with no timer to + // remove it. The delete deadline is still far ahead, so only the inclusive edit boundary can + // produce this answer. + let policy = windows(edit: 900, delete: 172_800) + let message = text("hi", from: me, sentAgo: 900) + #expect(resolve(message, policy: policy).contains(.edit)) + #expect(MessageCapability.nextExpiry( + among: [message], in: nil, as: me, policy: policy, now: now + ) == now) + } + + @Test("A message whose windows have all lapsed schedules nothing") + func fullyLapsedMessageSchedulesNothing() { + let policy = windows(edit: 900, delete: 172_800) + let expiry = MessageCapability.nextExpiry( + among: [text("old", from: me, sentAgo: 200_000)], in: nil, as: me, policy: policy, now: now + ) + #expect(expiry == nil) + } + + @Test("Messages with nothing to lose contribute no deadline") + func nonExpiringMessagesScheduleNothing() { + let policy = windows(edit: 900, delete: 172_800) + let theirs = text("hi", from: them) + let unconfirmed = text("hi", from: me, eventSequence: 0) + #expect(MessageCapability.nextExpiry( + among: [theirs, unconfirmed], in: nil, as: me, policy: policy, now: now + ) == nil) } - @Test("The default policy configures no edit window, so age never removes edit") - func defaultPolicyHasNoEditWindow() { - #expect(MessagePolicy.default.editWindow == nil) - #expect(resolve(text("hi", from: me, sentAgo: 86_400)) == [.copy, .edit, .delete]) + @Test("An unbounded policy schedules nothing — there is no deadline to wake for") + func unboundedPolicySchedulesNothing() { + #expect(MessageCapability.nextExpiry( + among: [text("hi", from: me)], in: nil, as: me, policy: windows(edit: nil, delete: nil), now: now + ) == nil) } }