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,
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)
+79 -64
View File
@@ -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)
}
}
+6 -2
View File
@@ -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 {
@@ -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