mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +00:00
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:
co-authored by
jack
Claude Fable 5
parent
3e9230229d
commit
35fb9fdd42
@@ -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)
|
||||
|
||||
@@ -65,7 +65,77 @@ struct BlockRevealImageView: 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 {
|
||||
Image(platformImage: image)
|
||||
.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
|
||||
@@ -235,8 +250,7 @@ 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 {
|
||||
private func handleTap() {
|
||||
guard !isSending, !loadFailed else { return }
|
||||
if isBlurred {
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
@@ -246,7 +260,9 @@ struct BlockRevealImageView: View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user