Republish mirrored conversations on shared-instance status changes

When a private message is mirrored into stable-key and ephemeral-peer
conversations as one shared BitchatMessage instance, the first
conversation's status apply mutated the shared object and the second
skipped as already-equal - state stayed correct but the mirrored
conversation never republished, so a view observing it rendered stale
delivery/read status. The ID-only fan-out now republishes and emits
.statusChanged for every skipped conversation whose message holds the
applied status; genuinely-rejected distinct copies (downgrades) stay
untouched, and duplicate acks still publish nothing.

Found by Codex review on #1334.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-11 14:09:43 +02:00
co-authored by Claude Fable 5
parent 38331e62f1
commit 8289a6d05e
2 changed files with 72 additions and 6 deletions
+32 -6
View File
@@ -135,6 +135,17 @@ final class Conversation: ObservableObject, Identifiable {
return true
}
/// Republishes a message without changing state. Used for mirrored
/// copies that share a BitchatMessage instance: the first conversation's
/// status apply mutated the shared object, so this conversation's
/// observers still need an @Published emission to re-render.
@discardableResult
fileprivate func republishMessage(withID messageID: String) -> Bool {
guard let index = indexByMessageID[messageID] else { return false }
messages[index] = messages[index]
return true
}
@discardableResult
fileprivate func setUnread(_ unread: Bool) -> Bool {
guard isUnread != unread else { return false }
@@ -345,17 +356,32 @@ final class ConversationStore: ObservableObject {
/// downgrade read beats delivered beats sent).
///
/// `BitchatMessage` is a reference type, so mirrored copies sharing one
/// instance are updated by the first apply; subsequent conversations
/// skip as already-equal (state stays correct everywhere, the
/// `.statusChanged` event fires for the conversation that applied).
/// instance are mutated by the first conversation's apply. The skipped
/// conversations still hold the changed message, so they get an explicit
/// republish and `.statusChanged` event - otherwise a view observing the
/// mirrored conversation would render stale status. Distinct copies whose
/// update was genuinely rejected (downgrade) are left untouched, guarded
/// by status equality.
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
guard let ids = conversationIDsByMessageID[messageID] else { return false }
var applied = false
for id in ids where setDeliveryStatus(status, forMessageID: messageID, in: id) {
applied = true
var skipped: [ConversationID] = []
for id in ids {
if setDeliveryStatus(status, forMessageID: messageID, in: id) {
applied = true
} else {
skipped.append(id)
}
}
return applied
guard applied else { return false }
for id in skipped {
guard let conversation = conversationsByID[id],
conversation.message(withID: messageID)?.deliveryStatus == status,
conversation.republishMessage(withID: messageID) else { continue }
changes.send(.statusChanged(id, messageID: messageID, status))
}
return true
}
/// Current delivery status of `messageID` in whichever conversation