diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9d08e0d1b..5b6a5f3ee 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" } }, { @@ -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/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/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index b9d2ebbc5..9babd2a7a 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -34,8 +34,8 @@ 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/flipcash2-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"), ] let package = Package( 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/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/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) } } 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))