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
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//
// ConversationController+MessageMutations.swift
// Flipcash
//
// Copyright © 2026 Code Inc. All rights reserved.
//

import Foundation
import FlipcashCore

nonisolated private let logger = Logger(label: "flipcash.conversation-controller")

/// What a mutation attempt did.
enum MutationOutcome: Equatable {
/// The server accepted it; the transcript shows the result.
case applied
/// Another client's change won; the transcript shows that change instead.
case conflicted
/// Nothing applied; the transcript has reverted to what it showed before.
case failed
}

@MainActor
extension ConversationController {

/// Replaces a message's text. The transcript updates immediately from an overlay; the server's
/// answer then replaces it, whether that answer is the edit or somebody else's.
@discardableResult
func edit(messageID: MessageID, in conversationID: ConversationID, to text: String) async -> MutationOutcome {
guard let current = confirmedMessage(messageID, in: conversationID), current.eventSequence > 0 else {
logger.error("Refusing to edit a message with no confirmed sequence", metadata: [
"conversationID": "\(conversationID)",
"messageID": "\(messageID)",
])
return .failed
}

store.applyMutation(
MutationEntry(messageID: messageID, kind: .edited(text), expectedSequence: current.eventSequence),
in: conversationID
)
bumpMessageRevision()

do {
let outcome = try await messaging.editMessage(
owner: owner,
conversationID: conversationID,
messageID: messageID,
text: text,
expectedEventSequence: current.eventSequence
)
settle(outcome, messageID: messageID, in: conversationID, operation: "edit-message")
if outcome.isConflict {
mutationAlert = MutationAlert(action: .edit, kind: .conflict)
return .conflicted
}
return .applied
} catch {
store.dropMutation(for: messageID, in: conversationID)
bumpMessageRevision()
logger.error("Failed to edit conversation message", metadata: [
"conversationID": "\(conversationID)",
"error": "\(error)",
])
ErrorReporting.captureError(error, reason: "Failed to edit conversation message")
mutationAlert = MutationAlert(action: .edit, kind: .failure)
return .failed
}
}

/// Deletes a message for everyone in the conversation. The row is not removed — it becomes a
/// tombstone, so message ordering stays gapless and a reply quoting it still has a target.
@discardableResult
func delete(messageID: MessageID, in conversationID: ConversationID) async -> MutationOutcome {
guard let current = confirmedMessage(messageID, in: conversationID), current.eventSequence > 0 else {
logger.error("Refusing to delete a message with no confirmed sequence", metadata: [
"conversationID": "\(conversationID)",
"messageID": "\(messageID)",
])
return .failed
}

store.applyMutation(
MutationEntry(messageID: messageID, kind: .deleted, expectedSequence: current.eventSequence),
in: conversationID
)
bumpMessageRevision()

do {
let outcome = try await messaging.deleteMessage(
owner: owner,
conversationID: conversationID,
messageID: messageID,
expectedEventSequence: current.eventSequence
)
settle(outcome, messageID: messageID, in: conversationID, operation: "delete-message")
if outcome.isConflict {
mutationAlert = MutationAlert(action: .delete, kind: .conflict)
return .conflicted
}
return .applied
} catch {
store.dropMutation(for: messageID, in: conversationID)
bumpMessageRevision()
logger.error("Failed to delete conversation message", metadata: [
"conversationID": "\(conversationID)",
"error": "\(error)",
])
ErrorReporting.captureError(error, reason: "Failed to delete conversation message")
mutationAlert = MutationAlert(action: .delete, kind: .failure)
return .failed
}
}

/// The stored copy. The overlay is deliberately not consulted: `expected_event_sequence` has to
/// come from server truth, or a second edit would send the sequence the first one optimistically
/// assumed and conflict against the server every time.
private func confirmedMessage(_ messageID: MessageID, in conversationID: ConversationID) -> ConversationMessage? {
do {
return try database.message(id: messageID, conversationID: conversationID)
} catch {
logger.error("Failed to read message for mutation", metadata: [
"conversationID": "\(conversationID)",
"error": "\(error)",
])
return nil
}
}

/// Persists whatever the server says the message now is and drops the overlay. Identical for an
/// accepted mutation and a conflict — a conflict's payload is the state that won, which is
/// exactly what has to land locally. There is no retry: reissuing would clobber the change that
/// beat this one.
private func settle(
_ outcome: MessageMutation,
messageID: MessageID,
in conversationID: ConversationID,
operation: String
) {
_ = persist(operation: operation) {
try database.upsertConversationMessages([outcome.message], conversationID: conversationID)
}
store.dropMutation(for: messageID, in: conversationID)
refreshFeedPreview(for: conversationID)
persistConversation(conversationID)
}
}
41 changes: 33 additions & 8 deletions Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,18 +100,18 @@ final class ConversationController {
/// it's excluded from observation.
@ObservationIgnored var visibleConversationID: ConversationID?

private var store = ConversationStore()
var store = ConversationStore()

/// The current blocklist (wired to `BlocklistController`), used to reconcile
/// which conversations are hidden from the feed.
@ObservationIgnored var blockedUserIDs: () -> Set<UserID> = { [] }

@ObservationIgnored private let fetching: any ConversationFetching
@ObservationIgnored private let messaging: any ConversationMessaging
@ObservationIgnored let messaging: any ConversationMessaging
@ObservationIgnored private let streaming: any ConversationEventStreaming
@ObservationIgnored private let contactNaming: any DMContactNaming
@ObservationIgnored private let database: Database
@ObservationIgnored private let owner: KeyPair
@ObservationIgnored let database: Database
@ObservationIgnored let owner: KeyPair
@ObservationIgnored private var startTask: Task<Void, Never>?
@ObservationIgnored private var streamTask: Task<Void, Never>?
@ObservationIgnored private var connectionStateTask: Task<Void, Never>?
Expand Down Expand Up @@ -695,7 +695,7 @@ final class ConversationController {

/// Recompute the feed row's preview from the newest persisted *visible* message (the store no longer
/// holds the confirmed transcript to derive it from).
private func refreshFeedPreview(for conversationID: ConversationID) {
func refreshFeedPreview(for conversationID: ConversationID) {
let visible = (try? database.latestMessage(conversationID: conversationID)) ?? nil
// A newest row that is itself invisible (a tombstone) is the one case the preview must regress —
// the never-regress guard would otherwise keep showing the deleted content.
Expand All @@ -709,7 +709,7 @@ final class ConversationController {

/// Persists the store's current version of a conversation. No-ops for
/// conversations the store doesn't know yet.
private func persistConversation(_ conversationID: ConversationID) {
func persistConversation(_ conversationID: ConversationID) {
guard let conversation = store.conversations.first(where: { $0.id == conversationID }) else { return }
persistConversation(conversation)
}
Expand All @@ -719,7 +719,7 @@ final class ConversationController {
}

@discardableResult
private func persist(operation: String, _ write: () throws -> Void) -> Bool {
func persist(operation: String, _ write: () throws -> Void) -> Bool {
do {
try write()
// A successful confirmed-message write invalidates the DB-backed transcript window; bump the
Expand All @@ -741,7 +741,7 @@ final class ConversationController {
/// Persist operations that write confirmed messages — the ones that must bump `messageRevision`.
private static let messageWriteOperations: Set<String> = [
"upsert-messages", "apply-chat-events", "delta-batch", "load-messages", "load-older",
"send-message", "reset-resync",
"send-message", "reset-resync", "edit-message", "delete-message",
]

// MARK: - Names
Expand Down Expand Up @@ -831,6 +831,31 @@ final class ConversationController {
/// re-fire through the store directly.
private(set) var messageRevision = 0

/// Forces the transcript to re-read its window. `persist(operation:)` does this for database
/// writes; an overlay change writes nothing, so it has to say so explicitly.
func bumpMessageRevision() {
messageRevision &+= 1
}

/// Set when a mutation needs to be reported to the person who made it. The screen presents it
/// and clears it. `nil` means there is nothing to report.
var mutationAlert: MutationAlert?

/// A mutation the user has to be told about, because the transcript alone will not explain it.
struct MutationAlert: Equatable, Identifiable {
enum Kind: String, Equatable {
/// Another client's change won; the transcript now shows that change, not this one.
case conflict
/// The request never applied; the transcript has reverted.
case failure
}

let action: MessageCapability
let kind: Kind

var id: String { "\(action.rawValue)-\(kind.rawValue)" }
}

/// The transcript's bounded window with the in-memory optimistic overlay applied: every confirmed
/// message from `startID` to the newest when anchored, else the newest `limit`. The DB is the source
/// of the confirmed rows; the store contributes only the pending overlay. Anchoring by id means an
Expand Down
6 changes: 6 additions & 0 deletions Flipcash/Core/Controllers/FlipClient+Protocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ protocol ConversationMessaging: AnyObject, Sendable {
onBatch: @MainActor @Sendable @escaping (_ messages: [ConversationMessage], _ checkpoint: UInt64?) -> Void
) async throws -> UInt64
func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, clientMessageID: UUID) async throws -> ConversationMessage
/// Replaces a message's text. `expectedEventSequence` is the optimistic-concurrency guard: the
/// server applies the edit only if the message still carries that sequence, and reports a
/// conflict with the winning state otherwise.
func editMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, text: String, expectedEventSequence: UInt64) async throws -> MessageMutation
/// Tombstones a message, guarded by `expectedEventSequence` the same way as `editMessage`.
func deleteMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, expectedEventSequence: UInt64) async throws -> MessageMutation
func markRead(owner: KeyPair, conversationID: ConversationID, messageID: MessageID) async throws
func notifyIsTyping(owner: KeyPair, conversationID: ConversationID, state: TypingState) async throws
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,40 @@ extension FlipClient {
}
}

public func editMessage(
owner: KeyPair,
conversationID: ConversationID,
messageID: MessageID,
text: String,
expectedEventSequence: UInt64
) async throws -> MessageMutation {
try await withCheckedThrowingContinuation { c in
chatMessagingService.editMessage(
owner: owner,
conversationID: conversationID,
messageID: messageID,
text: text,
expectedEventSequence: expectedEventSequence
) { c.resume(with: $0) }
}
}

public func deleteMessage(
owner: KeyPair,
conversationID: ConversationID,
messageID: MessageID,
expectedEventSequence: UInt64
) async throws -> MessageMutation {
try await withCheckedThrowingContinuation { c in
chatMessagingService.deleteMessage(
owner: owner,
conversationID: conversationID,
messageID: messageID,
expectedEventSequence: expectedEventSequence
) { c.resume(with: $0) }
}
}

public func markRead(owner: KeyPair, conversationID: ConversationID, messageID: MessageID) async throws {
try await withCheckedThrowingContinuation { c in
chatMessagingService.advancePointer(owner: owner, conversationID: conversationID, messageID: messageID) { c.resume(with: $0) }
Expand Down
Loading
Loading