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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ final class ConversationLoadCoordinator {
@ObservationIgnored private var lastInputs: Inputs?
@ObservationIgnored private var mapTask: Task<Void, Never>?

/// 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<Void, Never>?

/// 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,
Expand All @@ -47,6 +62,7 @@ final class ConversationLoadCoordinator {
let initial = currentInputs()
self.lastInputs = initial
self.items = Self.map(initial)
scheduleWindowExpiry(for: initial)
observeInputs()
}

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
)
}

Expand All @@ -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
)
}
)
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions FlipcashAPI/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,53 @@ extension MessageCapability {
return []
}

var capabilities: Set<MessageCapability> = [.copy, .delete]
if let window = policy.editWindow {
if now.timeIntervalSince(message.date) <= window {
capabilities.insert(.edit)
}
} else {
var capabilities: Set<MessageCapability> = [.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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
12 changes: 11 additions & 1 deletion FlipcashCore/Sources/FlipcashCore/Models/UserFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
)
}
}
Expand Down
Loading
Loading