Require confirmation before deleting a received image; label media controls (#1358)

* Require confirmation before deleting a received image; label media controls

Double-tapping a received image permanently deleted the message and its
file — no confirmation, no undo — while double-tap is the most ingrained
photo gesture on mobile, and it raced the reveal tap via
`.exclusively(before:)`. A mesh may never re-deliver that image, so an
accidental double-tap can destroy the only copy.

- Remove the double-tap-to-delete gesture. Delete moves into a
  long-press context menu behind a confirmation dialog ("this cannot be
  undone — the sender may not be in range to send it again"), alongside
  explicit open and hide-image actions (the swipe-to-re-blur was
  undiscoverable). Taps now only reveal and open.
- The blur overlay says "tap to reveal" instead of a bare eye-slash.
- Add the first accessibility support to these media views: labeled
  image states (hidden/revealed/sending) with custom actions, labeled
  voice play/pause with the duration as the value, and labeled cancel
  buttons.

Delete remains available and its underlying behavior is unchanged — it's
just gated. New strings are added source-language (en) only.

* Expose the in-flight cancel button to VoiceOver

The image tile uses accessibilityElement(children: .ignore), which
collapses the whole subtree — including the visible cancel button shown
while a send is in flight — into one element. VoiceOver users could not
cancel an in-progress image send. Add a cancel accessibility action for
the sending state.

* Mark the accessibility delete action destructive too

The context-menu delete already uses role: .destructive; the matching
accessibility action did not. Make them consistent.

* Deduplicate image actions and align accessibility labels with convention

Extract the open/hide/delete button set shared by the context menu and
accessibilityActions into a single @ViewBuilder so the two can't drift.
Move the interaction hints out of the accessibility labels into
accessibilityHint (labels stay nouns; "tap to reveal" was wrong for
VoiceOver activation anyway), and rename the blurred-state action to
"reveal image" since it reveals rather than opens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Offer cancel-send in the context menu while an image is sending

The context menu body was empty during sends, which some OS versions
still present as an empty preview. The accessibility path already
exposed a cancel-send action in that state; share the same button with
the context menu so pointer/touch users get a cancel path too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Label broken images honestly and drop actions that need the file

When the image file fails to load, the placeholder kept the "hidden
image"/"image" accessibility label with a reveal/open hint, and the
context menu still offered open/reveal on a URL that will not load.
Track the failed load, announce "image unavailable" with no interaction
hint, show a broken-photo glyph instead of an endless spinner, disable
the reveal/open gestures, and drop open/hide/reveal from the context
menu and accessibility actions -- keeping delete so received broken
attachments can still be cleaned up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hot Pixel Group
2026-07-06 10:07:18 +02:00
committed by GitHub
co-authored by jack jack Claude Fable 5
parent c8ceac1968
commit 0d251ad20c
3 changed files with 333 additions and 27 deletions
+192
View File
@@ -31204,6 +31204,186 @@
}
}
},
"media.accessibility.cancel_send" : {
"comment" : "Accessibility label for the cancel button on an in-flight media send",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "cancel sending"
}
}
}
},
"media.image.accessibility.hidden" : {
"comment" : "Accessibility label for a blurred incoming image",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "hidden image"
}
}
}
},
"media.image.accessibility.hint.open" : {
"comment" : "Accessibility hint for a revealed image; activating it opens the image full screen",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "opens the image full screen"
}
}
}
},
"media.image.accessibility.hint.reveal" : {
"comment" : "Accessibility hint for a blurred image; activating it reveals the image",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "reveals the image"
}
}
}
},
"media.image.accessibility.revealed" : {
"comment" : "Accessibility label for a revealed image",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "image"
}
}
}
},
"media.image.accessibility.sending" : {
"comment" : "Accessibility label for an image that is still sending",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "sending image"
}
}
}
},
"media.image.action.delete" : {
"comment" : "Context menu action that deletes a received image",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "delete image"
}
}
}
},
"media.image.action.hide" : {
"comment" : "Context menu action that re-blurs a revealed image",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "hide image"
}
}
}
},
"media.image.action.open" : {
"comment" : "Context menu action that opens an image full screen",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "open image"
}
}
}
},
"media.image.action.reveal" : {
"comment" : "Context menu action that reveals a blurred image",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "reveal image"
}
}
}
},
"media.image.delete_confirm_message" : {
"comment" : "Body of the confirmation dialog before deleting a received image",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "this cannot be undone — the sender may not be in range to send it again."
}
}
}
},
"media.image.delete_confirm_title" : {
"comment" : "Title of the confirmation dialog before deleting a received image",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "delete this image?"
}
}
}
},
"media.image.tap_to_reveal" : {
"comment" : "Caption on a blurred incoming image inviting a tap to reveal it",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "tap to reveal"
}
}
}
},
"media.voice.accessibility.pause" : {
"comment" : "Accessibility label for pausing voice note playback",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "pause voice note"
}
}
}
},
"media.voice.accessibility.play" : {
"comment" : "Accessibility label for playing a voice note",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "play voice note"
}
}
}
},
"mesh_peers.tooltip.new_messages" : {
"extractionState" : "manual",
"localizations" : {
@@ -36753,6 +36933,18 @@
}
}
}
},
"media.image.accessibility.unavailable" : {
"comment" : "Accessibility label for an image whose file could not be loaded",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "image unavailable"
}
}
}
}
},
"version" : "1.1"
+132 -27
View File
@@ -20,6 +20,25 @@ struct BlockRevealImageView: View {
@State private var platformImage: PlatformImage?
@State private var aspectRatio: CGFloat = 1
@State private var isBlurred: Bool = false
@State private var showDeleteConfirmation = false
@State private var loadFailed = false
private enum Strings {
static let tapToReveal = String(localized: "media.image.tap_to_reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it")
static let open = String(localized: "media.image.action.open", comment: "Context menu action that opens an image full screen")
static let reveal = String(localized: "media.image.action.reveal", comment: "Context menu action that reveals a blurred image")
static let hide = String(localized: "media.image.action.hide", comment: "Context menu action that re-blurs a revealed image")
static let delete = String(localized: "media.image.action.delete", comment: "Context menu action that deletes a received image")
static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", comment: "Title of the confirmation dialog before deleting a received image")
static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", comment: "Body of the confirmation dialog before deleting a received image")
static let hiddenImage = String(localized: "media.image.accessibility.hidden", comment: "Accessibility label for a blurred incoming image")
static let revealedImage = String(localized: "media.image.accessibility.revealed", comment: "Accessibility label for a revealed image")
static let revealHint = String(localized: "media.image.accessibility.hint.reveal", comment: "Accessibility hint for a blurred image; activating it reveals the image")
static let openHint = String(localized: "media.image.accessibility.hint.open", comment: "Accessibility hint for a revealed image; activating it opens the image full screen")
static let sendingImage = String(localized: "media.image.accessibility.sending", comment: "Accessibility label for an image that is still sending")
static let unavailableImage = String(localized: "media.image.accessibility.unavailable", comment: "Accessibility label for an image whose file could not be loaded")
static let cancelSend = String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
}
init(
url: URL,
@@ -69,9 +88,13 @@ struct BlockRevealImageView: View {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.black.opacity(0.35))
.overlay(
Image(systemName: "eye.slash.fill")
.font(.bitchatSystem(size: 24, weight: .semibold))
.foregroundColor(.white.opacity(0.85))
VStack(spacing: 6) {
Image(systemName: "eye.slash.fill")
.font(.bitchatSystem(size: 24, weight: .semibold))
Text(verbatim: Strings.tapToReveal)
.font(.bitchatSystem(size: 12, weight: .medium, design: .monospaced))
}
.foregroundColor(.white.opacity(0.85))
)
}
}
@@ -79,10 +102,16 @@ struct BlockRevealImageView: View {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.gray.opacity(0.2))
.frame(height: 200)
.overlay(
ProgressView()
.progressViewStyle(.circular)
)
.overlay {
if loadFailed {
Image(systemName: "photo")
.font(.bitchatSystem(size: 24, weight: .semibold))
.foregroundColor(.secondary)
} else {
ProgressView()
.progressViewStyle(.circular)
}
}
}
if let onCancel = onCancel, isSending {
@@ -95,6 +124,7 @@ struct BlockRevealImageView: View {
.padding(8)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.cancelSend)
}
}
.onAppear {
@@ -106,30 +136,105 @@ struct BlockRevealImageView: View {
loadImage()
}
.gesture(mainGesture)
}
private func loadImage() {
DispatchQueue.global(qos: .userInitiated).async {
#if os(iOS)
guard let image = UIImage(contentsOfFile: url.path) else { return }
#else
guard let image = NSImage(contentsOf: url) else { return }
#endif
let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1
DispatchQueue.main.async {
self.platformImage = image
self.aspectRatio = ratio
.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
}
}
}
private var mainGesture: some Gesture {
let doubleTap = TapGesture(count: 2).onEnded {
guard !isSending else { return }
onDelete?()
@ViewBuilder
private var cancelSendAction: some View {
if let onCancel {
Button(Strings.cancelSend, action: onCancel)
}
}
@ViewBuilder
private var imageActions: some View {
// Open/reveal/hide would act on a file that failed to load, so only
// offer delete (when available) to let users clean up the attachment.
if !loadFailed {
if isBlurred {
Button(Strings.reveal) {
withAnimation(.easeOut(duration: 0.2)) { isBlurred = false }
}
} else {
Button(Strings.open) { onOpen?() }
Button(Strings.hide) {
withAnimation(.easeInOut(duration: 0.2)) { isBlurred = true }
}
}
}
if onDelete != nil {
Button(Strings.delete, role: .destructive) { showDeleteConfirmation = true }
}
}
private var accessibilityLabelText: String {
if isSending { return Strings.sendingImage }
if loadFailed { return Strings.unavailableImage }
return isBlurred ? Strings.hiddenImage : Strings.revealedImage
}
private var accessibilityHintText: String {
if isSending || loadFailed { return "" }
return isBlurred ? Strings.revealHint : Strings.openHint
}
private func loadImage() {
loadFailed = false
DispatchQueue.global(qos: .userInitiated).async {
#if os(iOS)
let image = UIImage(contentsOfFile: url.path)
#else
let image = NSImage(contentsOf: url)
#endif
DispatchQueue.main.async {
guard let image else {
self.loadFailed = true
return
}
self.platformImage = image
self.aspectRatio = image.size.height > 0 ? image.size.width / image.size.height : 1
}
}
}
// Double-tap used to permanently delete the image the most ingrained
// 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 else { return }
guard !isSending, !loadFailed else { return }
if isBlurred {
withAnimation(.easeOut(duration: 0.2)) {
isBlurred = false
@@ -139,7 +244,7 @@ struct BlockRevealImageView: View {
}
}
let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in
guard !isSending else { return }
guard !isSending, !loadFailed else { return }
let horizontal = value.translation.width
let vertical = value.translation.height
guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return }
@@ -149,7 +254,7 @@ struct BlockRevealImageView: View {
}
}
}
return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe)
return singleTap.simultaneously(with: swipe)
}
}
+9
View File
@@ -50,6 +50,12 @@ struct VoiceNoteView: View {
.background(Circle().fill(palette.accent))
}
.buttonStyle(.plain)
.accessibilityLabel(
playback.isPlaying
? String(localized: "media.voice.accessibility.pause", comment: "Accessibility label for pausing voice note playback")
: String(localized: "media.voice.accessibility.play", comment: "Accessibility label for playing a voice note")
)
.accessibilityValue(playbackLabel)
WaveformView(
samples: samples,
@@ -74,6 +80,9 @@ struct VoiceNoteView: View {
.foregroundColor(.white)
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
)
}
}
.padding(12)