From d5c2116e6da0b6fd80fbe18c2160c6b10572c8af Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:27:40 -0400 Subject: [PATCH 1/2] feat(chat): gate edit and delete on the server's message windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ConversationLoadCoordinator.swift | 64 +++++++- .../Conversation/MessageCapability.swift | 47 +++++- .../Models/Conversation/MessagePolicy.swift | 65 +++++++- .../MessageCapabilityTests.swift | 145 ++++++++++++++++-- 4 files changed, 294 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..07190437a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -78,14 +78,49 @@ 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) + 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..cd44183b2 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,147 @@ 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 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) } } From 1a724d5b7da3b3721ece18fe77e313a4d83948f8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 16:53:56 -0400 Subject: [PATCH 2/2] fix(chat): keep the deadline that lands exactly on now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../Models/Conversation/MessageCapability.swift | 6 +++++- .../FlipcashCoreTests/MessageCapabilityTests.swift | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index 07190437a..d26b32ea9 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -116,7 +116,11 @@ extension MessageCapability { for capability in capabilities { guard let window = policy.window(for: capability) else { continue } let expiry = message.date.addingTimeInterval(window) - guard expiry > now else { continue } + // `>=`, 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 } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift index cd44183b2..ce0226849 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift @@ -181,6 +181,20 @@ struct MessageCapabilityTests { #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)