Fix received DM images stuck as grey box: stamp incoming media delivered, reveal tap as Button (#1402)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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: <local nickname>, at: <packet time>)`, 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 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-08 10:08:36 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent 3e9230229d
commit 35fb9fdd42
4 changed files with 97 additions and 67 deletions
@@ -113,7 +113,13 @@ final class BLEFileTransferHandler {
originalSender: nil, originalSender: nil,
isPrivate: deliveryPlan.isPrivateMessage, isPrivate: deliveryPlan.isPrivateMessage,
recipientNickname: nil, 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) SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
+75 -60
View File
@@ -65,7 +65,77 @@ struct BlockRevealImageView: View {
} }
var body: some View { var body: some View {
ZStack(alignment: .topTrailing) { // 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")
.font(.bitchatSystem(size: 12, weight: .bold))
.padding(8)
.background(Circle().fill(Color.black.opacity(0.7)))
.foregroundColor(.white)
.padding(8)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.cancelSend)
}
}
.simultaneousGesture(hideSwipe)
.onAppear {
isBlurred = initiallyBlurred
loadImage()
}
.onChange(of: url) { _ in
isBlurred = initiallyBlurred
loadImage()
}
.contextMenu {
if isSending {
cancelSendAction
} else {
imageActions
}
}
.confirmationDialog(
Strings.deleteConfirmTitle,
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button(Strings.delete, role: .destructive) {
onDelete?()
}
Button("common.cancel", role: .cancel) {}
} message: {
Text(verbatim: Strings.deleteConfirmMessage)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabelText)
.accessibilityHint(accessibilityHintText)
.accessibilityAddTraits(isSending || loadFailed ? [] : .isButton)
.accessibilityActions {
if isSending {
// children: .ignore collapses the visible cancel button, so
// expose it as an action while the send is in flight.
cancelSendAction
} else {
imageActions
}
}
}
@ViewBuilder
private var imageContent: some View {
if let image = platformImage { if let image = platformImage {
Image(platformImage: image) Image(platformImage: image)
.resizable() .resizable()
@@ -116,61 +186,6 @@ struct BlockRevealImageView: View {
} }
} }
} }
if let onCancel = onCancel, isSending {
Button(action: onCancel) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 12, weight: .bold))
.padding(8)
.background(Circle().fill(Color.black.opacity(0.7)))
.foregroundColor(.white)
.padding(8)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.cancelSend)
}
}
.onAppear {
isBlurred = initiallyBlurred
loadImage()
}
.onChange(of: url) { _ in
isBlurred = initiallyBlurred
loadImage()
}
.gesture(mainGesture)
.contextMenu {
if isSending {
cancelSendAction
} else {
imageActions
}
}
.confirmationDialog(
Strings.deleteConfirmTitle,
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button(Strings.delete, role: .destructive) {
onDelete?()
}
Button("common.cancel", role: .cancel) {}
} message: {
Text(verbatim: Strings.deleteConfirmMessage)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabelText)
.accessibilityHint(accessibilityHintText)
.accessibilityAddTraits(isSending || loadFailed ? [] : .isButton)
.accessibilityActions {
if isSending {
// children: .ignore collapses the visible cancel button, so
// expose it as an action while the send is in flight.
cancelSendAction
} else {
imageActions
}
}
} }
@ViewBuilder @ViewBuilder
@@ -235,8 +250,7 @@ struct BlockRevealImageView: View {
// photo gesture on mobile, racing the reveal tap, with no confirmation // 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 // and no way to get the file back. Delete now lives in the context menu
// behind a confirmation; taps only reveal and open. // behind a confirmation; taps only reveal and open.
private var mainGesture: some Gesture { private func handleTap() {
let singleTap = TapGesture().onEnded {
guard !isSending, !loadFailed else { return } guard !isSending, !loadFailed else { return }
if isBlurred { if isBlurred {
withAnimation(.easeOut(duration: 0.2)) { withAnimation(.easeOut(duration: 0.2)) {
@@ -246,7 +260,9 @@ struct BlockRevealImageView: View {
onOpen?() 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 } guard !isSending, !loadFailed else { return }
let horizontal = value.translation.width let horizontal = value.translation.width
let vertical = value.translation.height let vertical = value.translation.height
@@ -257,7 +273,6 @@ struct BlockRevealImageView: View {
} }
} }
} }
return singleTap.simultaneously(with: swipe)
} }
} }
+6 -2
View File
@@ -33,8 +33,8 @@ struct MediaMessageView: View {
} }
var body: some View { var body: some View {
let state = mediaSendState(for: deliveryStatus)
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message) let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
let state = mediaSendState(for: deliveryStatus, isFromMe: isFromMe)
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
// Baseline alignment (via the header text inside the VStack) keeps the // 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 isSending = false
var progress: Double? var progress: Double?
if let status = deliveryStatus { if let status = deliveryStatus {
@@ -77,6 +77,7 @@ struct BLEFileTransferHandlerTests {
#expect(message?.isPrivate == false) #expect(message?.isPrivate == false)
#expect(message?.senderPeerID == remotePeerID) #expect(message?.senderPeerID == remotePeerID)
#expect(message?.timestamp == Date(timeIntervalSince1970: 900)) #expect(message?.timestamp == Date(timeIntervalSince1970: 900))
#expect(message?.deliveryStatus == nil)
} }
@Test @Test
@@ -157,6 +158,10 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.lastSeenUpdates == [remotePeerID]) #expect(recorder.lastSeenUpdates == [remotePeerID])
#expect(recorder.deliveredMessages.count == 1) #expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.isPrivate == true) #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 @Test