diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 3e01bfd8..c64f6fd5 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -12,10 +12,23 @@ import BitFoundation struct TextMessageView: View { @Environment(\.colorScheme) private var colorScheme: ColorScheme @EnvironmentObject private var conversationUIModel: ConversationUIModel - + let message: BitchatMessage + /// Value snapshot of the message's mutable delivery status, captured at + /// construction. `BitchatMessage` is a reference type mutated in place by + /// `ConversationStore`, and SwiftUI compares reference-typed view fields + /// by identity — so a status-only change (e.g. delivered → read) on the + /// SAME instance would otherwise compare "unchanged" and this row's body + /// would be skipped even though the parent list re-rendered. Snapshotting + /// the enum makes the change visible to SwiftUI's structural diff. + private let deliveryStatus: DeliveryStatus? @State private var expandedMessageIDs: Set = [] - + + init(message: BitchatMessage) { + self.message = message + self.deliveryStatus = message.deliveryStatus + } + var body: some View { VStack(alignment: .leading, spacing: 0) { // Precompute heavy token scans once per row @@ -31,7 +44,7 @@ struct TextMessageView: View { // Delivery status indicator for private messages if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), - let status = message.deliveryStatus { + let status = deliveryStatus { DeliveryStatusView(status: status) .padding(.leading, 4) } diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index cc88fd03..4279aaa6 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -13,11 +13,24 @@ struct MediaMessageView: View { @EnvironmentObject private var conversationUIModel: ConversationUIModel let message: BitchatMessage let media: BitchatMessage.Media + /// Value snapshot of the message's mutable delivery status, captured at + /// construction (see `TextMessageView.deliveryStatus`): `BitchatMessage` + /// is a reference type mutated in place, and SwiftUI compares reference + /// fields by identity, so without the snapshot a status-only change + /// (send progress, delivered → read) would not re-render this row. + private let deliveryStatus: DeliveryStatus? @Binding var imagePreviewURL: URL? + init(message: BitchatMessage, media: BitchatMessage.Media, imagePreviewURL: Binding) { + self.message = message + self.media = media + self.deliveryStatus = message.deliveryStatus + self._imagePreviewURL = imagePreviewURL + } + var body: some View { - let state = mediaSendState(for: message) + let state = mediaSendState(for: deliveryStatus) let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message) let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil @@ -27,7 +40,7 @@ struct MediaMessageView: View { .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), - let status = message.deliveryStatus { + let status = deliveryStatus { DeliveryStatusView(status: status) .padding(.leading, 4) } @@ -63,10 +76,10 @@ struct MediaMessageView: View { .padding(.vertical, 4) } - private func mediaSendState(for message: BitchatMessage) -> (isSending: Bool, progress: Double?, canCancel: Bool) { + private func mediaSendState(for deliveryStatus: DeliveryStatus?) -> (isSending: Bool, progress: Double?, canCancel: Bool) { var isSending = false var progress: Double? - if let status = message.deliveryStatus { + if let status = deliveryStatus { switch status { case .sending: isSending = true diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 1308c637..0b70f0e4 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -298,6 +298,47 @@ struct AppArchitectureTests { #expect(inboxModel.messages(for: selectedPeerID).map(\.id) == ["dm-sel-1"]) } + @Test("PrivateInboxModel republishes read receipts for the selected DM (ephemeral- and stable-keyed)") + @MainActor + func privateInboxModelRepublishesReadReceiptsForSelectedConversation() { + // A DM's messages can live under BOTH .directPeer(ephemeral) and + // .directPeer(stableKey) (mirroring shares one BitchatMessage + // instance); the view's read-receipt update must fire no matter + // which of the two keys the selection holds. + let ephemeralPeerID = PeerID(str: "abcdef1234567890") + let stablePeerID = PeerID(str: String(repeating: "ab", count: 32)) + + for selectedPeerID in [ephemeralPeerID, stablePeerID] { + let store = ConversationStore() + let inboxModel = PrivateInboxModel(conversations: store) + store.setSelectedPrivatePeer(selectedPeerID) + + // One shared instance mirrored into both direct conversations, + // exactly like `mirrorToEphemeralIfNeeded`. + let message = makeArchitectureMessage( + id: "dm-read-1", + isPrivate: true, + senderPeerID: ephemeralPeerID + ) + store.append(message, to: .directPeer(ephemeralPeerID)) + store.upsertByID(message, in: .directPeer(stablePeerID)) + + var emissions = 0 + let cancellable = inboxModel.objectWillChange.sink { _ in emissions += 1 } + defer { cancellable.cancel() } + + // ID-only intent — the exact call `ChatDeliveryCoordinator` + // makes when a READ ack arrives. + let read = DeliveryStatus.read(by: "builder", at: Date(timeIntervalSince1970: 100)) + #expect(store.setDeliveryStatus(read, forMessageID: "dm-read-1")) + + // The fan-out emits .statusChanged for both containing + // conversations; exactly the selected one republishes the model. + #expect(emissions == 1) + #expect(inboxModel.messages(for: selectedPeerID).first?.deliveryStatus == read) + } + } + @Test("PublicChatModel ignores appends to background conversations") @MainActor func publicChatModelIsolatesBackgroundConversations() { diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index 06d5b1df..0d6f99c6 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -629,6 +629,52 @@ struct ViewSmokeTests { #expect(playback.progress == 0) } + @Test + func messageRows_snapshotDeliveryStatusForSwiftUIDiffing() { + // Regression: `BitchatMessage` is a reference type mutated in place + // by `ConversationStore.applyDeliveryStatus`, and SwiftUI compares + // reference-typed view fields by identity — so a status-only change + // (delivered → read) on the SAME instance is invisible to the row's + // structural diff and its body gets skipped even when the list + // re-renders. The row views must therefore snapshot the status as a + // value-typed stored property at init, so a rebuilt row value + // compares different and re-renders. + func deliveryStatusSnapshot(of row: Any) -> DeliveryStatus? { + Mirror(reflecting: row).children + .first { $0.label == "deliveryStatus" }? + .value as? DeliveryStatus + } + + let delivered = DeliveryStatus.delivered(to: "builder", at: Date(timeIntervalSince1970: 50)) + let message = BitchatMessage( + id: "dm-status-1", + sender: "anon", + content: "hello", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "builder", + senderPeerID: PeerID(str: "abcdef1234567890"), + deliveryStatus: delivered + ) + + #expect(deliveryStatusSnapshot(of: TextMessageView(message: message)) == delivered) + + // In-place mutation of the shared instance (what the store does on a + // READ ack); a freshly built row must carry the new status value. + let read = DeliveryStatus.read(by: "builder", at: Date(timeIntervalSince1970: 100)) + message.deliveryStatus = read + + #expect(deliveryStatusSnapshot(of: TextMessageView(message: message)) == read) + + let mediaRow = MediaMessageView( + message: message, + media: .image(URL(fileURLWithPath: "/tmp/never-loaded.jpg")), + imagePreviewURL: .constant(nil) + ) + #expect(deliveryStatusSnapshot(of: mediaRow) == read) + } + #if os(iOS) @Test func cameraScannerView_previewAndCoordinatorSmoke() {