mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:05:20 +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,
|
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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user