Skip to content

Commit fcff619

Browse files
authored
feat(chat): edit and delete RPCs with optimistic reconciliation (#714)
Third of the four PRs adding edit and delete to chat messages, on top of #713. The transport and the optimistic path. After this, edit and delete work end to end through the controller; only the UI that calls them is missing. ## Contents **Edit and delete RPCs.** `ChatMessagingService` wraps them, with the client protocol and mock updated so tests can drive both outcomes. **Optimistic overlay.** `ConversationStore` holds a pending mutation over the stored message rather than mutating it, so the transcript shows the change immediately and the stored row stays the server's version until the server agrees. `MutationEntry` is what gets overlaid. **Reconciliation.** `ConversationController+MessageMutations` applies the overlay, sends the request, and either clears the entry when the server confirms or rolls it back when it does not. Tests cover the overlay's effect on reads, and the controller's confirm and rollback paths for both edit and delete.
1 parent 405f8cc commit fcff619

10 files changed

Lines changed: 783 additions & 8 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
//
2+
// ConversationController+MessageMutations.swift
3+
// Flipcash
4+
//
5+
// Copyright © 2026 Code Inc. All rights reserved.
6+
//
7+
8+
import Foundation
9+
import FlipcashCore
10+
11+
nonisolated private let logger = Logger(label: "flipcash.conversation-controller")
12+
13+
/// What a mutation attempt did.
14+
enum MutationOutcome: Equatable {
15+
/// The server accepted it; the transcript shows the result.
16+
case applied
17+
/// Another client's change won; the transcript shows that change instead.
18+
case conflicted
19+
/// Nothing applied; the transcript has reverted to what it showed before.
20+
case failed
21+
}
22+
23+
@MainActor
24+
extension ConversationController {
25+
26+
/// Replaces a message's text. The transcript updates immediately from an overlay; the server's
27+
/// answer then replaces it, whether that answer is the edit or somebody else's.
28+
@discardableResult
29+
func edit(messageID: MessageID, in conversationID: ConversationID, to text: String) async -> MutationOutcome {
30+
guard let current = confirmedMessage(messageID, in: conversationID), current.eventSequence > 0 else {
31+
logger.error("Refusing to edit a message with no confirmed sequence", metadata: [
32+
"conversationID": "\(conversationID)",
33+
"messageID": "\(messageID)",
34+
])
35+
return .failed
36+
}
37+
38+
store.applyMutation(
39+
MutationEntry(messageID: messageID, kind: .edited(text), expectedSequence: current.eventSequence),
40+
in: conversationID
41+
)
42+
bumpMessageRevision()
43+
44+
do {
45+
let outcome = try await messaging.editMessage(
46+
owner: owner,
47+
conversationID: conversationID,
48+
messageID: messageID,
49+
text: text,
50+
expectedEventSequence: current.eventSequence
51+
)
52+
settle(outcome, messageID: messageID, in: conversationID, operation: "edit-message")
53+
if outcome.isConflict {
54+
mutationAlert = MutationAlert(action: .edit, kind: .conflict)
55+
return .conflicted
56+
}
57+
return .applied
58+
} catch {
59+
store.dropMutation(for: messageID, in: conversationID)
60+
bumpMessageRevision()
61+
logger.error("Failed to edit conversation message", metadata: [
62+
"conversationID": "\(conversationID)",
63+
"error": "\(error)",
64+
])
65+
ErrorReporting.captureError(error, reason: "Failed to edit conversation message")
66+
mutationAlert = MutationAlert(action: .edit, kind: .failure)
67+
return .failed
68+
}
69+
}
70+
71+
/// Deletes a message for everyone in the conversation. The row is not removed — it becomes a
72+
/// tombstone, so message ordering stays gapless and a reply quoting it still has a target.
73+
@discardableResult
74+
func delete(messageID: MessageID, in conversationID: ConversationID) async -> MutationOutcome {
75+
guard let current = confirmedMessage(messageID, in: conversationID), current.eventSequence > 0 else {
76+
logger.error("Refusing to delete a message with no confirmed sequence", metadata: [
77+
"conversationID": "\(conversationID)",
78+
"messageID": "\(messageID)",
79+
])
80+
return .failed
81+
}
82+
83+
store.applyMutation(
84+
MutationEntry(messageID: messageID, kind: .deleted, expectedSequence: current.eventSequence),
85+
in: conversationID
86+
)
87+
bumpMessageRevision()
88+
89+
do {
90+
let outcome = try await messaging.deleteMessage(
91+
owner: owner,
92+
conversationID: conversationID,
93+
messageID: messageID,
94+
expectedEventSequence: current.eventSequence
95+
)
96+
settle(outcome, messageID: messageID, in: conversationID, operation: "delete-message")
97+
if outcome.isConflict {
98+
mutationAlert = MutationAlert(action: .delete, kind: .conflict)
99+
return .conflicted
100+
}
101+
return .applied
102+
} catch {
103+
store.dropMutation(for: messageID, in: conversationID)
104+
bumpMessageRevision()
105+
logger.error("Failed to delete conversation message", metadata: [
106+
"conversationID": "\(conversationID)",
107+
"error": "\(error)",
108+
])
109+
ErrorReporting.captureError(error, reason: "Failed to delete conversation message")
110+
mutationAlert = MutationAlert(action: .delete, kind: .failure)
111+
return .failed
112+
}
113+
}
114+
115+
/// The stored copy. The overlay is deliberately not consulted: `expected_event_sequence` has to
116+
/// come from server truth, or a second edit would send the sequence the first one optimistically
117+
/// assumed and conflict against the server every time.
118+
private func confirmedMessage(_ messageID: MessageID, in conversationID: ConversationID) -> ConversationMessage? {
119+
do {
120+
return try database.message(id: messageID, conversationID: conversationID)
121+
} catch {
122+
logger.error("Failed to read message for mutation", metadata: [
123+
"conversationID": "\(conversationID)",
124+
"error": "\(error)",
125+
])
126+
return nil
127+
}
128+
}
129+
130+
/// Persists whatever the server says the message now is and drops the overlay. Identical for an
131+
/// accepted mutation and a conflict — a conflict's payload is the state that won, which is
132+
/// exactly what has to land locally. There is no retry: reissuing would clobber the change that
133+
/// beat this one.
134+
private func settle(
135+
_ outcome: MessageMutation,
136+
messageID: MessageID,
137+
in conversationID: ConversationID,
138+
operation: String
139+
) {
140+
_ = persist(operation: operation) {
141+
try database.upsertConversationMessages([outcome.message], conversationID: conversationID)
142+
}
143+
store.dropMutation(for: messageID, in: conversationID)
144+
refreshFeedPreview(for: conversationID)
145+
persistConversation(conversationID)
146+
}
147+
}

‎Flipcash/Core/Controllers/ConversationController.swift‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -100,18 +100,18 @@ final class ConversationController {
100100
/// it's excluded from observation.
101101
@ObservationIgnored var visibleConversationID: ConversationID?
102102

103-
private var store = ConversationStore()
103+
var store = ConversationStore()
104104

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

109109
@ObservationIgnored private let fetching: any ConversationFetching
110-
@ObservationIgnored private let messaging: any ConversationMessaging
110+
@ObservationIgnored let messaging: any ConversationMessaging
111111
@ObservationIgnored private let streaming: any ConversationEventStreaming
112112
@ObservationIgnored private let contactNaming: any DMContactNaming
113-
@ObservationIgnored private let database: Database
114-
@ObservationIgnored private let owner: KeyPair
113+
@ObservationIgnored let database: Database
114+
@ObservationIgnored let owner: KeyPair
115115
@ObservationIgnored private var startTask: Task<Void, Never>?
116116
@ObservationIgnored private var streamTask: Task<Void, Never>?
117117
@ObservationIgnored private var connectionStateTask: Task<Void, Never>?
@@ -695,7 +695,7 @@ final class ConversationController {
695695

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

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

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

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

834+
/// Forces the transcript to re-read its window. `persist(operation:)` does this for database
835+
/// writes; an overlay change writes nothing, so it has to say so explicitly.
836+
func bumpMessageRevision() {
837+
messageRevision &+= 1
838+
}
839+
840+
/// Set when a mutation needs to be reported to the person who made it. The screen presents it
841+
/// and clears it. `nil` means there is nothing to report.
842+
var mutationAlert: MutationAlert?
843+
844+
/// A mutation the user has to be told about, because the transcript alone will not explain it.
845+
struct MutationAlert: Equatable, Identifiable {
846+
enum Kind: String, Equatable {
847+
/// Another client's change won; the transcript now shows that change, not this one.
848+
case conflict
849+
/// The request never applied; the transcript has reverted.
850+
case failure
851+
}
852+
853+
let action: MessageCapability
854+
let kind: Kind
855+
856+
var id: String { "\(action.rawValue)-\(kind.rawValue)" }
857+
}
858+
834859
/// The transcript's bounded window with the in-memory optimistic overlay applied: every confirmed
835860
/// message from `startID` to the newest when anchored, else the newest `limit`. The DB is the source
836861
/// of the confirmed rows; the store contributes only the pending overlay. Anchoring by id means an

‎Flipcash/Core/Controllers/FlipClient+Protocols.swift‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ protocol ConversationMessaging: AnyObject, Sendable {
8282
onBatch: @MainActor @Sendable @escaping (_ messages: [ConversationMessage], _ checkpoint: UInt64?) -> Void
8383
) async throws -> UInt64
8484
func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, clientMessageID: UUID) async throws -> ConversationMessage
85+
/// Replaces a message's text. `expectedEventSequence` is the optimistic-concurrency guard: the
86+
/// server applies the edit only if the message still carries that sequence, and reports a
87+
/// conflict with the winning state otherwise.
88+
func editMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, text: String, expectedEventSequence: UInt64) async throws -> MessageMutation
89+
/// Tombstones a message, guarded by `expectedEventSequence` the same way as `editMessage`.
90+
func deleteMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, expectedEventSequence: UInt64) async throws -> MessageMutation
8591
func markRead(owner: KeyPair, conversationID: ConversationID, messageID: MessageID) async throws
8692
func notifyIsTyping(owner: KeyPair, conversationID: ConversationID, state: TypingState) async throws
8793
}

‎FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,40 @@ extension FlipClient {
6161
}
6262
}
6363

64+
public func editMessage(
65+
owner: KeyPair,
66+
conversationID: ConversationID,
67+
messageID: MessageID,
68+
text: String,
69+
expectedEventSequence: UInt64
70+
) async throws -> MessageMutation {
71+
try await withCheckedThrowingContinuation { c in
72+
chatMessagingService.editMessage(
73+
owner: owner,
74+
conversationID: conversationID,
75+
messageID: messageID,
76+
text: text,
77+
expectedEventSequence: expectedEventSequence
78+
) { c.resume(with: $0) }
79+
}
80+
}
81+
82+
public func deleteMessage(
83+
owner: KeyPair,
84+
conversationID: ConversationID,
85+
messageID: MessageID,
86+
expectedEventSequence: UInt64
87+
) async throws -> MessageMutation {
88+
try await withCheckedThrowingContinuation { c in
89+
chatMessagingService.deleteMessage(
90+
owner: owner,
91+
conversationID: conversationID,
92+
messageID: messageID,
93+
expectedEventSequence: expectedEventSequence
94+
) { c.resume(with: $0) }
95+
}
96+
}
97+
6498
public func markRead(owner: KeyPair, conversationID: ConversationID, messageID: MessageID) async throws {
6599
try await withCheckedThrowingContinuation { c in
66100
chatMessagingService.advancePointer(owner: owner, conversationID: conversationID, messageID: messageID) { c.resume(with: $0) }

0 commit comments

Comments
 (0)