Files
hapi/ios/Packages/HapiKit/Sources/HapiProtocol/Window/WindowMessage.swift
T
weishu 5ef4304a4a fix: synchronize read state across web and native clients
Track readable content revisions and durable read cursors in the hub. Acknowledge only rendered foreground tails, persist offline read operations, and preserve newer manual unread choices across devices.

Migrate existing history to a read baseline and cover pagination receipts with shared native conformance fixtures.
2026-11-01 09:52:51 +08:00

306 lines
12 KiB
Swift

import Foundation
/// Client-side send state of an optimistic user message. The web reference
/// extends the wire `DecryptedMessage` with `status?: MessageStatus`
/// (`web/src/types/api.ts`); the wire never carries it — servers echo rows
/// without a status and the client re-attaches it during merge.
public enum MessageStatus: String, Codable, Sendable {
case queued
case sending
case sent
case failed
case indeterminate
}
/// Wire tri-state of `invokedAt` (`pagination.md` "Queued semantics"):
/// an absent key means already-invoked (pre-V8 hubs omit the field), an
/// explicit `null` means still queued, a number is the invocation time.
/// The strict-null queued check and the fixture projection both need the
/// three states kept apart, which the collapsed `DecryptedMessage.invokedAt`
/// (`Int?`) cannot do.
public enum InvokedAtField: Equatable, Sendable {
case absent
case null
case number(Int)
/// Collapsed JS view (`invokedAt ?? …` semantics): the number, or `nil`
/// for both `null` and absent.
public var numberValue: Int? {
if case .number(let value) = self { return value }
return nil
}
/// JS `invokedAt !== undefined` — the wire carried the key.
public var isPresent: Bool { self != .absent }
}
/// One row of the message window: the wire `DecryptedMessage` fields plus the
/// client-side ``status``. Mirrors the web's `DecryptedMessage & {status?}`.
///
/// Deliberately a **class**: `applyLatestResponse`'s request-baseline
/// comparison is by reference (web `!==`, Android object identity), so rows
/// need identity, and transitions must only create new instances for rows
/// they actually change — which every function in this package does.
/// ``Equatable`` is therefore identity (`===`) as well: two deep-equal rows
/// are different instances on purpose (that difference is what classifies a
/// row as "changed since the request left" during a reset replace).
///
/// Instances are immutable (`let` storage), so the class is safely `Sendable`.
public final class WindowMessage: Sendable, Equatable {
// MARK: Wire fields (`DecryptedMessageSchema`, with tri-state invokedAt)
/// Server uuid. Optimistic rows use the client `localId` until echoed.
public let contentRevision: Int?
public let id: String
/// Per-session insert counter; `nil` on optimistic rows.
public let seq: Int?
/// Client-generated id for optimistic reconciliation.
public let localId: String?
/// Role-wrapped envelope, kept wire-verbatim.
public let content: JSONValue
/// Hub receive time (epoch ms).
public let createdAt: Int
/// Invocation time, kept tri-state (see ``InvokedAtField``).
public let invokedAt: InvokedAtField
/// Future-scheduled send time (epoch ms), when set.
public let scheduledAt: Int?
// MARK: Client-side
public let status: MessageStatus?
public init(
contentRevision: Int? = nil,
id: String,
seq: Int? = nil,
localId: String? = nil,
content: JSONValue = .null,
createdAt: Int,
invokedAt: InvokedAtField = .absent,
scheduledAt: Int? = nil,
status: MessageStatus? = nil
) {
self.contentRevision = contentRevision
self.id = id
self.seq = seq
self.localId = localId
self.content = content
self.createdAt = createdAt
self.invokedAt = invokedAt
self.scheduledAt = scheduledAt
self.status = status
}
/// Wrap a wire row. `DecryptedMessage` collapses the invokedAt tri-state
/// to `Int?`; `nil` maps to an **explicit null** here because V8+ hubs
/// always send the key (see the caveat on `DecryptedMessage.invokedAt` —
/// if pre-V8 hub support is ever needed, the wire model must learn the
/// tri-state and this initializer picks it up).
public convenience init(wire: DecryptedMessage, status: MessageStatus? = nil) {
self.init(
contentRevision: wire.contentRevision,
id: wire.id,
seq: wire.seq,
localId: wire.localId,
content: wire.content,
createdAt: wire.createdAt,
invokedAt: wire.invokedAt.map(InvokedAtField.number) ?? .null,
scheduledAt: wire.scheduledAt,
status: status ?? (wire.deliveryState == "indeterminate" ? .indeterminate : nil)
)
}
/// Identity equality — see the type comment.
public static func == (lhs: WindowMessage, rhs: WindowMessage) -> Bool {
lhs === rhs
}
// MARK: Derived
/// Collapsed invocation time (JS `invokedAt ?? …` operand).
public var invokedAtNumber: Int? { invokedAt.numberValue }
/// Position time `invokedAt ?? createdAt` (`pagination.md` "Position key").
public var positionAt: Int { invokedAt.numberValue ?? createdAt }
/// JS `message.invokedAt === null`.
public var hasExplicitNullInvokedAt: Bool { invokedAt == .null }
/// A row is optimistic iff it has a localId and `id === localId`
/// (web `optimisticMessage`).
public var isOptimistic: Bool {
localId != nil && id == localId
}
/// Web `isUserMessage` (`web/src/lib/messages.ts`): the content envelope
/// is an object whose `role` is the string `'user'`.
public var isUserMessage: Bool {
content.objectValue?["role"]?.stringValue == "user"
}
/// Web `isQueuedForInvocation`: a user message whose `invokedAt` is an
/// explicit `null` and whose send did not fail. Only these rows sit in
/// the queued bar and survive window trims.
public var isQueuedForInvocation: Bool {
isUserMessage && hasExplicitNullInvokedAt && status != .failed
}
// MARK: Copies (each returns a NEW instance — identity change is meaningful)
/// Copy with `invokedAt` stamped to an explicit number.
public func withInvokedAt(_ invokedAt: Int) -> WindowMessage {
WindowMessage(
contentRevision: contentRevision,
id: id, seq: seq, localId: localId, content: content,
createdAt: createdAt, invokedAt: .number(invokedAt),
scheduledAt: scheduledAt, status: status
)
}
public func withDeliveryState(_ state: String?) -> WindowMessage {
withStatus(state == "indeterminate" ? .indeterminate : (status ?? .queued))
}
/// Copy with the client-side status replaced.
public func withStatus(_ status: MessageStatus) -> WindowMessage {
WindowMessage(
contentRevision: contentRevision,
id: id, seq: seq, localId: localId, content: content,
createdAt: createdAt, invokedAt: invokedAt,
scheduledAt: scheduledAt, status: status
)
}
/// The chat pipeline's collapsed wire view of this row (feeds
/// `normalizeDecryptedMessage`).
public var asDecryptedMessage: DecryptedMessage {
DecryptedMessage(
contentRevision: contentRevision,
id: id,
seq: seq,
localId: localId,
content: content,
createdAt: createdAt,
invokedAt: invokedAt.numberValue,
scheduledAt: scheduledAt,
deliveryState: status == .indeterminate ? "indeterminate" : nil
)
}
}
// MARK: - Codable (wire object plus an optional `status` key)
/// Serializes as the wire object plus an optional `status` key — the same
/// shape the web persists (and the pagination fixtures use for op inputs).
/// `invokedAt` round-trips the tri-state: key omitted when absent, `null`
/// when explicit null.
extension WindowMessage: Codable {
private enum CodingKeys: String, CodingKey {
case contentRevision
case id, seq, localId, content, createdAt, invokedAt, scheduledAt, status
}
public convenience init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let invokedAt: InvokedAtField
if container.contains(.invokedAt) {
if try container.decodeNil(forKey: .invokedAt) {
invokedAt = .null
} else {
invokedAt = .number(try container.decode(Int.self, forKey: .invokedAt))
}
} else {
invokedAt = .absent
}
self.init(
contentRevision: try container.decodeIfPresent(Int.self, forKey: .contentRevision),
id: try container.decode(String.self, forKey: .id),
seq: try container.decodeIfPresent(Int.self, forKey: .seq),
localId: try container.decodeIfPresent(String.self, forKey: .localId),
content: try container.decodeIfPresent(JSONValue.self, forKey: .content) ?? .null,
createdAt: try container.decode(Int.self, forKey: .createdAt),
invokedAt: invokedAt,
scheduledAt: try container.decodeIfPresent(Int.self, forKey: .scheduledAt),
// Unknown status strings degrade to nil (Android `fromWire`) —
// a new client-side state must not break snapshot hydration.
status: (try container.decodeIfPresent(String.self, forKey: .status))
.flatMap(MessageStatus.init(rawValue:))
)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(contentRevision, forKey: .contentRevision)
try container.encode(id, forKey: .id)
// Optional-encoding writes explicit `null`s for seq/localId, matching
// the wire rows the hub emits.
try container.encode(seq, forKey: .seq)
try container.encode(localId, forKey: .localId)
try container.encode(content, forKey: .content)
try container.encode(createdAt, forKey: .createdAt)
switch invokedAt {
case .absent:
break
case .null:
try container.encodeNil(forKey: .invokedAt)
case .number(let value):
try container.encode(value, forKey: .invokedAt)
}
try container.encodeIfPresent(scheduledAt, forKey: .scheduledAt)
try container.encodeIfPresent(status?.rawValue, forKey: .status)
}
}
// MARK: - Optimistic row construction
/// Builds the optimistic row appended on send, mirroring
/// `createOptimisticMessage` in `web/src/hooks/mutations/useSendMessage.ts`
/// and the contract's "Optimistic sends" lifecycle: `id = localId`,
/// `seq = null`, explicit `invokedAt: null` (so the strict-null queued check
/// matches), content `{role:'user', content:{type:'text', text, attachments?},
/// meta:{deliveryMode}}`.
public func buildOptimisticMessage(
localId: String,
text: String,
createdAt: Int,
attachments: [AttachmentMetadata]? = nil,
scheduledAt: Int? = nil,
deliveryMode: String = "queue",
status: MessageStatus = .sending
) -> WindowMessage {
var inner: [String: JSONValue] = [
"type": .string("text"),
"text": .string(text),
]
if let attachments {
inner["attachments"] = .array(attachments.map { attachment in
var object: [String: JSONValue] = [
"id": .string(attachment.id),
"filename": .string(attachment.filename),
"mimeType": .string(attachment.mimeType),
"size": .number(Double(attachment.size)),
"path": .string(attachment.path),
]
if let previewUrl = attachment.previewUrl {
object["previewUrl"] = .string(previewUrl)
}
return .object(object)
})
}
let content: JSONValue = .object([
"role": .string("user"),
"content": .object(inner),
"meta": .object(["deliveryMode": .string(deliveryMode)]),
])
return WindowMessage(
id: localId,
seq: nil,
localId: localId,
content: content,
createdAt: createdAt,
invokedAt: .null,
scheduledAt: scheduledAt,
status: status
)
}