From 35fb9fdd42617fe7f232ffc39bea6ae87004446f Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:08:36 +0200 Subject: [PATCH] Fix received DM images stuck as grey box: stamp incoming media delivered, reveal tap as Button (#1402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make image tap-to-reveal a Button so the DM sheet can't swallow it Received images in a DM rendered as a grey "tap to reveal" box that never revealed: the DM sheet wraps the whole conversation in a high-priority swipe-to-close DragGesture (ContentSheetViews), and an ancestor high-priority gesture starves descendant TapGestures — the image's reveal/open tap never fired. Button actions survive that suppression (the sheet's own header buttons work for the same reason), so the tap now lives on a plain-style Button wrapping the image; swipe-to-hide stays attached as a simultaneous gesture. Fixes #1388 Co-Authored-By: Claude Fable 5 * Move the cancel control out of the reveal Button Nested buttons don't get reliable independent hit testing, and the outer reveal tap is a no-op while sending, so the in-flight cancel x could become untappable. The cancel overlay now sits on the Button rather than inside its label. Co-Authored-By: Claude Fable 5 * Root cause: received media stuck in .sending — grey box, dead tap Field testing showed the Button conversion alone didn't fix #1388: a received DM image still rendered as a flat grey box with a dead tap. The real culprit is BitchatMessage's initializer, which defaults every private message without an explicit status to `.sending`. BLEFileTransferHandler built incoming media messages without one, so every received private image/voice note was permanently "sending": mediaSendState returned progress 0 → BlockRevealMask rendered 0% of the image (the flat grey box is the blur overlay over an empty mask), and the reveal tap was disabled by the isSending guard — regardless of which gesture carried it. Two layers: - BLEFileTransferHandler now stamps incoming private media `.delivered(to: , at: )`, matching the received-text path in ChatPrivateConversationCoordinator. - MediaMessageView treats received messages as never-sending, so no other construction path can reproduce the grey-box state. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- .../Services/BLE/BLEFileTransferHandler.swift | 8 +- .../Views/Media/BlockRevealImageView.swift | 143 ++++++++++-------- bitchat/Views/Media/MediaMessageView.swift | 8 +- .../BLEFileTransferHandlerTests.swift | 5 + 4 files changed, 97 insertions(+), 67 deletions(-) diff --git a/bitchat/Services/BLE/BLEFileTransferHandler.swift b/bitchat/Services/BLE/BLEFileTransferHandler.swift index 43d13aa9..177741f8 100644 --- a/bitchat/Services/BLE/BLEFileTransferHandler.swift +++ b/bitchat/Services/BLE/BLEFileTransferHandler.swift @@ -113,7 +113,13 @@ final class BLEFileTransferHandler { originalSender: nil, isPrivate: deliveryPlan.isPrivateMessage, recipientNickname: nil, - senderPeerID: peerID + senderPeerID: peerID, + // Received messages need an explicit status: BitchatMessage + // defaults private messages to .sending, which the media views + // render as an in-flight send (empty reveal mask, disabled tap). + deliveryStatus: deliveryPlan.isPrivateMessage + ? .delivered(to: env.localNickname(), at: ts) + : nil ) SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) diff --git a/bitchat/Views/Media/BlockRevealImageView.swift b/bitchat/Views/Media/BlockRevealImageView.swift index 8e5d8bd2..01b5d8cb 100644 --- a/bitchat/Views/Media/BlockRevealImageView.swift +++ b/bitchat/Views/Media/BlockRevealImageView.swift @@ -65,58 +65,19 @@ struct BlockRevealImageView: View { } var body: some View { - ZStack(alignment: .topTrailing) { - if let image = platformImage { - Image(platformImage: image) - .resizable() - .aspectRatio(aspectRatio, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.gray.opacity(0.2), lineWidth: 1) - ) - .mask( - BlockRevealMask( - fraction: fraction, - columns: 24, - rows: 16 - ) - .animation(.easeOut(duration: 0.2), value: fraction) - ) - .blur(radius: isBlurred ? 20 : 0) - .overlay { - if isBlurred { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.black.opacity(0.35)) - .overlay( - VStack(spacing: 6) { - Image(systemName: "eye.slash.fill") - .font(.bitchatSystem(size: 24, weight: .semibold)) - Text(verbatim: Strings.tapToReveal) - // Themed: monospaced under matrix, - // system under liquid glass. - .bitchatFont(size: 12, weight: .medium) - } - .foregroundColor(.white.opacity(0.85)) - ) - } - } - } else { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(palette.secondary.opacity(0.2)) - .frame(height: 200) - .overlay { - if loadFailed { - Image(systemName: "photo") - .font(.bitchatSystem(size: 24, weight: .semibold)) - .foregroundColor(palette.secondary) - } else { - ProgressView() - .progressViewStyle(.circular) - } - } - } - + // The DM sheet wraps the conversation in a high-priority + // swipe-to-close DragGesture (ContentSheetViews). An ancestor + // high-priority gesture starves descendant TapGestures, but Button + // actions still fire — the reveal/open tap must stay a Button or + // received DM images become untappable. + Button(action: handleTap) { + imageContent + } + .buttonStyle(.plain) + // The cancel control must sit outside the Button label: nested + // buttons don't get reliable independent hit testing, and the outer + // tap is a no-op while sending — the x could become untappable. + .overlay(alignment: .topTrailing) { if let onCancel = onCancel, isSending { Button(action: onCancel) { Image(systemName: "xmark") @@ -130,6 +91,7 @@ struct BlockRevealImageView: View { .accessibilityLabel(Strings.cancelSend) } } + .simultaneousGesture(hideSwipe) .onAppear { isBlurred = initiallyBlurred loadImage() @@ -138,7 +100,6 @@ struct BlockRevealImageView: View { isBlurred = initiallyBlurred loadImage() } - .gesture(mainGesture) .contextMenu { if isSending { cancelSendAction @@ -173,6 +134,60 @@ struct BlockRevealImageView: View { } } + @ViewBuilder + private var imageContent: some View { + if let image = platformImage { + Image(platformImage: image) + .resizable() + .aspectRatio(aspectRatio, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + .mask( + BlockRevealMask( + fraction: fraction, + columns: 24, + rows: 16 + ) + .animation(.easeOut(duration: 0.2), value: fraction) + ) + .blur(radius: isBlurred ? 20 : 0) + .overlay { + if isBlurred { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.black.opacity(0.35)) + .overlay( + VStack(spacing: 6) { + Image(systemName: "eye.slash.fill") + .font(.bitchatSystem(size: 24, weight: .semibold)) + Text(verbatim: Strings.tapToReveal) + // Themed: monospaced under matrix, + // system under liquid glass. + .bitchatFont(size: 12, weight: .medium) + } + .foregroundColor(.white.opacity(0.85)) + ) + } + } + } else { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(palette.secondary.opacity(0.2)) + .frame(height: 200) + .overlay { + if loadFailed { + Image(systemName: "photo") + .font(.bitchatSystem(size: 24, weight: .semibold)) + .foregroundColor(palette.secondary) + } else { + ProgressView() + .progressViewStyle(.circular) + } + } + } + } + @ViewBuilder private var cancelSendAction: some View { if let onCancel { @@ -235,18 +250,19 @@ struct BlockRevealImageView: View { // photo gesture on mobile, racing the reveal tap, with no confirmation // and no way to get the file back. Delete now lives in the context menu // behind a confirmation; taps only reveal and open. - private var mainGesture: some Gesture { - let singleTap = TapGesture().onEnded { - guard !isSending, !loadFailed else { return } - if isBlurred { - withAnimation(.easeOut(duration: 0.2)) { - isBlurred = false - } - } else { - onOpen?() + private func handleTap() { + guard !isSending, !loadFailed else { return } + if isBlurred { + withAnimation(.easeOut(duration: 0.2)) { + isBlurred = false } + } else { + onOpen?() } - let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in + } + + private var hideSwipe: some Gesture { + DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in guard !isSending, !loadFailed else { return } let horizontal = value.translation.width let vertical = value.translation.height @@ -257,7 +273,6 @@ struct BlockRevealImageView: View { } } } - return singleTap.simultaneously(with: swipe) } } diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index 2534aedf..dd3b79b0 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -33,8 +33,8 @@ struct MediaMessageView: View { } var body: some View { - let state = mediaSendState(for: deliveryStatus) let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message) + let state = mediaSendState(for: deliveryStatus, isFromMe: isFromMe) let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil // Baseline alignment (via the header text inside the VStack) keeps the @@ -127,7 +127,11 @@ struct MediaMessageView: View { } } - private func mediaSendState(for deliveryStatus: DeliveryStatus?) -> (isSending: Bool, progress: Double?, canCancel: Bool) { + private func mediaSendState(for deliveryStatus: DeliveryStatus?, isFromMe: Bool) -> (isSending: Bool, progress: Double?, canCancel: Bool) { + // A received message is never in a send state: BitchatMessage defaults + // private messages to .sending, so an incoming message's status must + // not drive the reveal mask or disable the reveal tap. + guard isFromMe else { return (false, nil, false) } var isSending = false var progress: Double? if let status = deliveryStatus { diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index 518c97c0..2e3d2450 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -77,6 +77,7 @@ struct BLEFileTransferHandlerTests { #expect(message?.isPrivate == false) #expect(message?.senderPeerID == remotePeerID) #expect(message?.timestamp == Date(timeIntervalSince1970: 900)) + #expect(message?.deliveryStatus == nil) } @Test @@ -157,6 +158,10 @@ struct BLEFileTransferHandlerTests { #expect(recorder.lastSeenUpdates == [remotePeerID]) #expect(recorder.deliveredMessages.count == 1) #expect(recorder.deliveredMessages.first?.isPrivate == true) + // Must be explicit: BitchatMessage defaults private messages to + // .sending, which the media views render as an in-flight send + // (empty reveal mask, disabled reveal tap). + #expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900))) } @Test