diff --git a/bitchat/Services/BLE/BLEFileTransferHandler.swift b/bitchat/Services/BLE/BLEFileTransferHandler.swift index 43d13aa9..177741f8 100644 --- a/bitchat/Services/BLE/BLEFileTransferHandler.swift +++ b/bitchat/Services/BLE/BLEFileTransferHandler.swift @@ -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) diff --git a/bitchat/Views/Media/BlockRevealImageView.swift b/bitchat/Views/Media/BlockRevealImageView.swift index 8e5d8bd2..01b5d8cb 100644 --- a/bitchat/Views/Media/BlockRevealImageView.swift +++ b/bitchat/Views/Media/BlockRevealImageView.swift @@ -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) } } diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index 2534aedf..dd3b79b0 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -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 { diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index 518c97c0..2e3d2450 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -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