523 lines
25 KiB
Swift
523 lines
25 KiB
Swift
import Foundation
|
|
import Network
|
|
import Observation
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class SybilAgentRoom {
|
|
struct Snapshot: Equatable {
|
|
var room = AgentRoomState()
|
|
var isTyping = false
|
|
var entranceIDs: Set<String> = []
|
|
var revision = 0
|
|
}
|
|
private(set) var snapshot = Snapshot()
|
|
private(set) var connection = "Offline"
|
|
private(set) var error: String?
|
|
private(set) var isLoading = false
|
|
private(set) var requiresReload = false
|
|
private(set) var outbox: [AgentOutboxRow] = []
|
|
let attachmentDraft = AgentAttachmentDraft()
|
|
private(set) var attachmentSessionID = UUID()
|
|
private var attachmentModals: Set<UUID> = []
|
|
var isAttachmentModalPresented: Bool { !attachmentModals.isEmpty }
|
|
func setAttachmentModal(_ id: UUID, presented: Bool) {
|
|
if presented { attachmentModals.insert(id) } else { attachmentModals.remove(id) }
|
|
}
|
|
func dismissAttachmentPresentations() {
|
|
// Existing picker/share views observe this identity and dismiss themselves.
|
|
// Keep draft contents and transcript/view identity intact during routing.
|
|
attachmentSessionID = UUID()
|
|
}
|
|
var composer: String {
|
|
get { attachmentDraft.caption }
|
|
set { attachmentDraft.caption = newValue }
|
|
}
|
|
var isObscured = false
|
|
var isModalPresented = false
|
|
var isSelected = false
|
|
private(set) var isActive = true
|
|
@ObservationIgnored var onAcknowledgedRead: ((AgentReadState) -> Void)?
|
|
private enum ReadSourceBinding: Equatable {
|
|
case unbound, legacy, source(String)
|
|
}
|
|
@ObservationIgnored private var readBinding: ReadSourceBinding = .unbound
|
|
private var readSourceID: String? {
|
|
if case let .source(id) = readBinding { return id }
|
|
return nil
|
|
}
|
|
|
|
@ObservationIgnored private var attachmentDownloads = AgentAttachmentDownloads()
|
|
@ObservationIgnored private var client: (any AgentAPIClienting)?
|
|
@ObservationIgnored private var storage: AgentOutboxStorage?
|
|
@ObservationIgnored private var storageReadable = true
|
|
@ObservationIgnored private var pending = AgentRoomState()
|
|
@ObservationIgnored private var presenter = AgentPresentation()
|
|
@ObservationIgnored private var pendingEntrances: Set<String> = []
|
|
@ObservationIgnored private var generation = UUID()
|
|
@ObservationIgnored private var connectionTask: Task<Void, Never>?
|
|
@ObservationIgnored private var maintenanceTask: Task<Void, Never>?
|
|
@ObservationIgnored private var batchTask: Task<Void, Never>?
|
|
@ObservationIgnored private var typingTask: Task<Void, Never>?
|
|
@ObservationIgnored private var readTask: Task<Void, Never>?
|
|
@ObservationIgnored private var sends: [String: Task<Void, Never>] = [:]
|
|
@ObservationIgnored private var socket: (any AgentSocket)?
|
|
@ObservationIgnored private var monitor: NWPathMonitor?
|
|
@ObservationIgnored private var lastPacket = Date()
|
|
@ObservationIgnored private var socketStarted = Date()
|
|
@ObservationIgnored private var desiredRead = "0"
|
|
@ObservationIgnored private var acknowledgedRead = "0"
|
|
@ObservationIgnored private var hasLoaded = false
|
|
@ObservationIgnored private var syncing = false
|
|
@ObservationIgnored private var wasNetworkOffline = false
|
|
|
|
var isBusy: Bool { snapshot.room.activity.isBusy }
|
|
var unreadCount: Int { snapshot.room.unreadCount }
|
|
var messages: [Message] {
|
|
#if DEBUG
|
|
AgentRenderDiagnostics.record("messageProjection")
|
|
#endif
|
|
let committed = Set(snapshot.room.messages.map(\.id))
|
|
let queued = snapshot.room.activity.queuedMessages.filter { !committed.contains($0.id) }
|
|
var messages = AgentRoomState.merge(snapshot.room.messages, queued)
|
|
for row in outbox {
|
|
if let index = messages.firstIndex(where: { $0.id == row.id }) {
|
|
messages[index].createdAt = row.createdAt
|
|
if !row.confirmed {
|
|
var info = messages[index].metadata?.objectValue ?? [:]
|
|
if let attachments = row.attachments, messages[index].agentAttachments.map(\.id) != attachments.map(\.id) {
|
|
info["agentAttachments"] = .array(attachments.map(\.metadataValue))
|
|
messages[index].content = row.content
|
|
}
|
|
info["status"] = .string(row.status)
|
|
if let error = row.error { info["error"] = .string(error) }
|
|
messages[index].metadata = .object(info)
|
|
}
|
|
} else { messages.append(row.message) }
|
|
}
|
|
return AgentRoomState.merge([], messages)
|
|
}
|
|
|
|
func configure(client: any AgentAPIClienting, storage: AgentOutboxStorage, draftStorage: AgentDraftStorage? = nil) {
|
|
stop()
|
|
self.client = client
|
|
self.storage = storage
|
|
attachmentSessionID = UUID()
|
|
let oldDownloads = attachmentDownloads
|
|
Task { await oldDownloads.invalidate() }
|
|
attachmentDownloads = AgentAttachmentDownloads()
|
|
attachmentDraft.configure(client: client, storage: draftStorage)
|
|
resetHistory()
|
|
error = nil; storageReadable = true
|
|
do {
|
|
outbox = try storage.read().map { row in
|
|
var restored = row
|
|
if !restored.seenOnServer {
|
|
restored.status = "failed"
|
|
restored.error = "Delivery was interrupted. Retry to confirm."
|
|
}
|
|
return restored
|
|
}
|
|
if outbox.contains(where: { $0.draftID == attachmentDraft.id }) {
|
|
attachmentDraft.clearAfterSubmission()
|
|
}
|
|
} catch {
|
|
outbox = []; storageReadable = false
|
|
self.error = "Could not restore pending messages: \(error.localizedDescription)"
|
|
}
|
|
start()
|
|
}
|
|
|
|
func disconnect() {
|
|
stop()
|
|
client = nil
|
|
attachmentDraft.updateCapability(nil)
|
|
attachmentSessionID = UUID()
|
|
let oldDownloads = attachmentDownloads
|
|
Task { await oldDownloads.invalidate() }
|
|
attachmentDownloads = AgentAttachmentDownloads()
|
|
}
|
|
|
|
func stop() {
|
|
attachmentDraft.suspend()
|
|
generation = UUID()
|
|
connectionTask?.cancel(); connectionTask = nil
|
|
maintenanceTask?.cancel(); maintenanceTask = nil
|
|
batchTask?.cancel(); batchTask = nil
|
|
typingTask?.cancel(); typingTask = nil
|
|
readTask?.cancel(); readTask = nil
|
|
sends.values.forEach { $0.cancel() }; sends = [:]
|
|
for index in outbox.indices where !outbox[index].seenOnServer && outbox[index].status == "sending" {
|
|
outbox[index].status = "failed"
|
|
outbox[index].error = "Delivery was interrupted. Retry to confirm."
|
|
}
|
|
if !outbox.isEmpty { try? persist() }
|
|
monitor?.cancel(); monitor = nil
|
|
let old = socket; socket = nil
|
|
Task { await old?.close() }
|
|
syncing = false
|
|
pendingEntrances = []
|
|
connection = "Offline"
|
|
}
|
|
|
|
func setActive(_ active: Bool) {
|
|
guard isActive != active else { return }
|
|
isActive = active
|
|
if active { attachmentDraft.resume(); start() }
|
|
else {
|
|
// Publish successfully merged input before cancelling; replay resumes here.
|
|
flush()
|
|
stop()
|
|
}
|
|
}
|
|
|
|
func retryConnection() {
|
|
flush(); stop()
|
|
// Explicit Reconnect starts a new history/source boundary. Foreground and
|
|
// network recovery must not relabel the preserved old room automatically.
|
|
if requiresReload { resetHistory(); error = nil }
|
|
if isActive { start() }
|
|
}
|
|
|
|
private func resetHistory() {
|
|
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
|
|
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false
|
|
readBinding = .unbound; requiresReload = false
|
|
}
|
|
|
|
private func requireReload(_ reason: String) -> AgentFailure {
|
|
let failure = AgentFailure(message: reason + " Reconnect to load fresh Agent history.")
|
|
requiresReload = true
|
|
// Preserve already accepted old cursors/history, cancel every old writer,
|
|
// and invalidate callbacks before allowing any further source observation.
|
|
flush(); stop()
|
|
isLoading = false; connection = "Reload required"; error = failure.localizedDescription
|
|
return failure
|
|
}
|
|
|
|
private func start() {
|
|
guard !requiresReload, isActive, let client, connectionTask == nil else { return }
|
|
let run = generation
|
|
isLoading = !hasLoaded
|
|
connection = "Connecting"
|
|
let monitor = NWPathMonitor()
|
|
monitor.pathUpdateHandler = { [weak self] path in
|
|
let satisfied = path.status == .satisfied
|
|
Task { @MainActor [weak self] in
|
|
guard let self, self.generation == run else { return }
|
|
let recovered = self.wasNetworkOffline && satisfied
|
|
self.wasNetworkOffline = !satisfied
|
|
if recovered && !self.requiresReload { self.retryConnection() }
|
|
}
|
|
}
|
|
monitor.start(queue: DispatchQueue(label: "net.buzzert.sybil.agent-network"))
|
|
self.monitor = monitor
|
|
connectionTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
var delay = 1
|
|
while !Task.isCancelled, self.generation == run {
|
|
do {
|
|
let descriptor = try await client.agentDescriptor()
|
|
guard self.generation == run else { return }
|
|
self.attachmentDraft.updateCapability(descriptor.capabilities?.attachments)
|
|
self.attachmentDraft.resume()
|
|
try self.receive(.activity(descriptor.id, descriptor.activity), replay: !self.hasLoaded)
|
|
try await self.refreshRead(client: client, run: run)
|
|
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
|
|
guard self.generation == run, !Task.isCancelled else { return }
|
|
self.hasLoaded = true; self.isLoading = false; self.flush()
|
|
let socket = try await client.agentSocket(after: self.pending.cursor)
|
|
guard self.generation == run, !Task.isCancelled else { await socket.close(); return }
|
|
self.socket = socket
|
|
self.socketStarted = Date(); self.lastPacket = Date()
|
|
while !Task.isCancelled, self.generation == run {
|
|
let data = try await socket.receive()
|
|
guard self.generation == run, !Task.isCancelled else { return }
|
|
self.lastPacket = Date()
|
|
guard let event = try AgentEvent.decode(data) else { continue }
|
|
if case .ready = event { self.connection = "Connected"; self.error = nil; delay = 1 }
|
|
try self.receive(event)
|
|
}
|
|
} catch {
|
|
guard self.generation == run, !Task.isCancelled else { return }
|
|
let failedSocket = self.socket
|
|
let code = await failedSocket?.closeCode()
|
|
await failedSocket?.close()
|
|
guard self.generation == run, !Task.isCancelled else { return }
|
|
self.socket = nil
|
|
self.isLoading = false
|
|
let attachmentsUnavailable = code == 4503 || (error as? AgentAttachmentIssue)?.code == "attachments_unavailable"
|
|
if attachmentsUnavailable { self.attachmentDraft.updateCapability(nil) }
|
|
self.error = code == 4401 ? "Agent authentication failed. Check your Sybil token in Settings."
|
|
: code == 4503 ? "Agent attachments are unavailable. The server requires a safely authenticated Agent connection. [attachments_unavailable, 4503]"
|
|
: error.localizedDescription
|
|
self.connection = code == 4401 ? "Offline" : "Reconnecting"
|
|
if code == 4401 { return }
|
|
}
|
|
try? await Task.sleep(for: .seconds(delay))
|
|
delay = min(delay * 2, 30)
|
|
}
|
|
}
|
|
maintenanceTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(15))
|
|
guard let self, self.generation == run, !Task.isCancelled else { return }
|
|
do {
|
|
try await self.refreshRead(client: client, run: run)
|
|
guard self.generation == run, !Task.isCancelled else { return }
|
|
self.flushRead()
|
|
if self.connection != "Connected" {
|
|
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
|
|
}
|
|
guard self.generation == run, !Task.isCancelled else { return }
|
|
if let socket = self.socket {
|
|
if Date().timeIntervalSince(self.lastPacket) > 60 || (self.connection != "Connected" && Date().timeIntervalSince(self.socketStarted) > 12) {
|
|
await socket.close()
|
|
} else { try await socket.send("{\"type\":\"ping\"}") }
|
|
}
|
|
} catch {
|
|
if self.generation == run { self.error = error.localizedDescription }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func catchUp(client: any AgentAPIClienting, run: UUID? = nil, replay: Bool = false) async throws {
|
|
let run = run ?? generation
|
|
guard !requiresReload, generation == run, !Task.isCancelled, !syncing else { return }
|
|
syncing = true
|
|
defer { if generation == run { syncing = false } }
|
|
var cursor = pending.cursor
|
|
while !Task.isCancelled, generation == run {
|
|
let page = try await client.agentMessages(after: cursor, before: nil)
|
|
guard generation == run, !Task.isCancelled else { return }
|
|
try page.validate()
|
|
let next = try AgentCursor.normalize(page.cursor ?? cursor)
|
|
guard !AgentCursor.less(next, cursor), !page.hasMore || next != cursor else {
|
|
throw AgentFailure(message: "Agent history did not advance its cursor.")
|
|
}
|
|
try receive(.messages(page), replay: replay)
|
|
cursor = next
|
|
if !page.hasMore { return }
|
|
}
|
|
}
|
|
|
|
func receive(_ event: AgentEvent, replay: Bool = false) throws {
|
|
guard !requiresReload else { throw AgentFailure(message: error ?? "Agent history requires reload.") }
|
|
let before = pending
|
|
var next = pending
|
|
switch event {
|
|
case let .messages(page):
|
|
try next.apply(page)
|
|
// History has no source field. Pin even an empty first page before
|
|
// publishing it so a later UUID cannot adopt legacy cursor space.
|
|
if readBinding == .unbound { readBinding = .legacy }
|
|
case let .activity(id, activity):
|
|
try next.checkConversation(id)
|
|
next.activity = activity
|
|
case let .ready(id): if let id { try next.checkConversation(id) }
|
|
case let .error(message): throw AgentFailure(message: message)
|
|
case let .readState(read): try applyRead(read); return
|
|
case .heartbeat: return
|
|
}
|
|
presenter.receive(event, before: before, after: next, replay: replay, now: Date())
|
|
pending = next
|
|
if !replay {
|
|
let oldIDs = Set(before.messages.map(\.id)).union(outbox.map(\.id))
|
|
pendingEntrances.formUnion(next.messages.filter { !oldIDs.contains($0.id) && $0.role != .tool }.map(\.id))
|
|
}
|
|
reconcileOutbox()
|
|
schedulePublish()
|
|
}
|
|
|
|
private func schedulePublish() {
|
|
guard batchTask == nil else { return }
|
|
batchTask = Task { [weak self] in
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
guard !Task.isCancelled else { return }
|
|
self?.flush()
|
|
}
|
|
}
|
|
|
|
func flush() {
|
|
#if DEBUG
|
|
AgentRenderDiagnostics.record("snapshotPublish")
|
|
#endif
|
|
batchTask?.cancel(); batchTask = nil
|
|
typingTask?.cancel(); typingTask = nil
|
|
snapshot = Snapshot(room: pending, isTyping: presenter.isTyping(now: Date()), entranceIDs: pendingEntrances, revision: snapshot.revision + 1)
|
|
pendingEntrances = []
|
|
if let start = presenter.busySince, !snapshot.isTyping {
|
|
let delay = max(0.01, 0.25 - Date().timeIntervalSince(start))
|
|
typingTask = Task { [weak self] in
|
|
try? await Task.sleep(for: .seconds(delay))
|
|
guard !Task.isCancelled else { return }
|
|
self?.flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func refreshRead(client: any AgentAPIClienting, run: UUID) async throws {
|
|
let read = try await client.agentReadState()
|
|
guard generation == run else { return }
|
|
try applyRead(read)
|
|
}
|
|
func applyRead(_ read: AgentReadState) throws {
|
|
guard !requiresReload else { throw AgentFailure(message: error ?? "Agent history requires reload.") }
|
|
let cursor = try AgentCursor.normalize(read.cursor)
|
|
let incoming: ReadSourceBinding
|
|
if let source = read.sourceId {
|
|
guard AgentPushPayload.validSource(source) else { throw requireReload("Agent read source is invalid.") }
|
|
incoming = .source(source.lowercased())
|
|
} else { incoming = .legacy }
|
|
guard readBinding == .unbound || readBinding == incoming else {
|
|
throw requireReload("Agent read source changed.")
|
|
}
|
|
readBinding = incoming
|
|
acknowledgedRead = try AgentCursor.advance(acknowledgedRead, cursor)
|
|
pending.readCursor = acknowledgedRead
|
|
schedulePublish()
|
|
onAcknowledgedRead?(read)
|
|
}
|
|
func markRead(renderedCursor: String) {
|
|
guard !requiresReload, hasLoaded, isActive, isSelected, !isObscured, !isModalPresented, !isAttachmentModalPresented else { return }
|
|
do { desiredRead = try AgentCursor.advance(desiredRead, renderedCursor); flushRead() }
|
|
catch { self.error = error.localizedDescription }
|
|
}
|
|
private func flushRead() {
|
|
guard !requiresReload, readBinding != .unbound, let client, readTask == nil, AgentCursor.less(acknowledgedRead, desiredRead) else { return }
|
|
let run = generation
|
|
readTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
defer { if self.generation == run { self.readTask = nil } }
|
|
do {
|
|
while !self.requiresReload, self.generation == run, AgentCursor.less(self.acknowledgedRead, self.desiredRead), !Task.isCancelled {
|
|
let captured = self.desiredRead
|
|
let read = try await client.agentMarkRead(cursor: captured, sourceID: self.readSourceID)
|
|
guard self.generation == run, !Task.isCancelled else { return }
|
|
guard let cursor = try? AgentCursor.normalize(read.cursor), !AgentCursor.less(cursor, captured) else {
|
|
throw self.requireReload("Agent read state was not acknowledged.")
|
|
}
|
|
try self.applyRead(read)
|
|
}
|
|
} catch {
|
|
if self.generation == run {
|
|
let isHTTPConflict: Bool
|
|
if case APIError.httpError(statusCode: 409, message: _) = error { isHTTPConflict = true }
|
|
else { isHTTPConflict = (error as? AgentAttachmentIssue)?.statusCode == 409 }
|
|
if isHTTPConflict {
|
|
_ = self.requireReload("Agent read source changed.")
|
|
} else { self.error = error.localizedDescription }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func downloadAttachment(_ attachment: AgentAttachment) async throws -> Data {
|
|
guard let client else { throw AgentAttachmentIssue.unavailable }
|
|
let session = attachmentSessionID
|
|
let data = try await attachmentDownloads.data(for: attachment, client: client)
|
|
try Task.checkCancellation()
|
|
guard attachmentSessionID == session else { throw CancellationError() }
|
|
return data
|
|
}
|
|
|
|
@discardableResult
|
|
func sendComposer() -> Bool {
|
|
do {
|
|
let attachments = try attachmentDraft.validateReady()
|
|
guard attachmentDraft.canSend, send(composer, attachments: attachments, draftID: attachmentDraft.id) else { return false }
|
|
return true
|
|
} catch { self.error = error.localizedDescription; return false }
|
|
}
|
|
|
|
@discardableResult
|
|
func send(_ content: String, attachments: [AgentAttachment] = [], draftID: String? = nil) -> Bool {
|
|
guard !requiresReload else { return false }
|
|
do {
|
|
_ = try AgentSubmission(content: content, requestID: UUID().uuidString, attachments: attachments)
|
|
if !attachments.isEmpty {
|
|
guard let limits = attachmentDraft.capability, attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable }
|
|
try limits.validate(attachments)
|
|
}
|
|
} catch { self.error = error.localizedDescription; return false }
|
|
guard storageReadable, client != nil else { error = "Pending messages could not be restored. Reconnect before sending."; return false }
|
|
let row = AgentOutboxRow(requestID: UUID().uuidString, content: content, createdAt: Date(), attachments: attachments.isEmpty ? nil : attachments, draftID: draftID)
|
|
outbox.append(row)
|
|
do { if !attachments.isEmpty || draftID != nil { try persist() } }
|
|
catch {
|
|
outbox.removeAll { $0.id == row.id }
|
|
self.error = "Could not save message: \(error.localizedDescription)"
|
|
return false // Keep the entire draft when durable handoff fails.
|
|
}
|
|
pendingEntrances.insert(row.id)
|
|
presenter.sent(now: Date(), activity: pending.activity)
|
|
flush()
|
|
if draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() {
|
|
outbox[outbox.count - 1].status = "failed"
|
|
outbox[outbox.count - 1].error = attachmentDraft.error
|
|
try? persist()
|
|
return false
|
|
}
|
|
dispatch(row.id)
|
|
return true
|
|
}
|
|
func retry(_ id: String) { dispatch(id) }
|
|
private func persist() throws {
|
|
guard let storage, storageReadable else { throw AgentFailure(message: "Message storage is unavailable.") }
|
|
try storage.write(outbox.filter { !$0.confirmed })
|
|
}
|
|
private func dispatch(_ id: String) {
|
|
guard !requiresReload else { return }
|
|
guard let client, sends[id] == nil, let index = outbox.firstIndex(where: { $0.id == id }), !outbox[index].seenOnServer else { return }
|
|
if outbox[index].draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() { return }
|
|
outbox[index].status = "sending"; outbox[index].error = nil
|
|
do { try persist() }
|
|
catch { outbox[index].status = "failed"; outbox[index].error = "Could not save message: \(error.localizedDescription)"; return }
|
|
let row = outbox[index], run = generation
|
|
sends[id] = Task { [weak self] in
|
|
guard let self else { return }
|
|
defer { if self.generation == run { self.sends[id] = nil } }
|
|
do {
|
|
let attachments = row.attachments ?? []
|
|
if !attachments.isEmpty {
|
|
guard let limits = self.attachmentDraft.capability, self.attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable }
|
|
try limits.validate(attachments)
|
|
}
|
|
let submission = try AgentSubmission(content: row.content, requestID: row.requestID, attachments: attachments)
|
|
let receipt = try await client.agentSubmit(submission)
|
|
try receipt.validate(requestID: row.requestID, attachmentIDs: submission.attachmentIDs)
|
|
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].confirmed else { return }
|
|
self.outbox[index].seenOnServer = true
|
|
self.outbox[index].status = receipt.status == "queued" ? "queued" : "complete"
|
|
self.outbox[index].error = nil
|
|
try? self.persist()
|
|
} catch {
|
|
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].seenOnServer else { return }
|
|
self.outbox[index].status = "failed"; self.outbox[index].error = error.localizedDescription
|
|
try? self.persist()
|
|
}
|
|
}
|
|
}
|
|
private func reconcileOutbox() {
|
|
let committed = pending.messages
|
|
let queued = pending.activity.queuedMessages
|
|
var changed = false
|
|
for index in outbox.indices {
|
|
let row = outbox[index]
|
|
let matches: (Message) -> Bool = { message in
|
|
message.id == row.id && ((row.attachments ?? []).isEmpty || message.agentAttachments.map(\.id) == row.attachments?.map(\.id))
|
|
}
|
|
if committed.contains(where: matches) {
|
|
if !outbox[index].confirmed { changed = true }
|
|
outbox[index].confirmed = true; outbox[index].seenOnServer = true
|
|
outbox[index].status = "complete"; outbox[index].error = nil
|
|
} else if queued.contains(where: matches), !outbox[index].confirmed {
|
|
changed = true
|
|
outbox[index].seenOnServer = true; outbox[index].status = "queued"; outbox[index].error = nil
|
|
}
|
|
}
|
|
if changed { try? persist() }
|
|
}
|
|
}
|