mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45: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,
|
||||
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,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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