diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 4b1a4a16..2f430bb9 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -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" diff --git a/bitchat/Views/Media/BlockRevealImageView.swift b/bitchat/Views/Media/BlockRevealImageView.swift index 5801888c..163665a5 100644 --- a/bitchat/Views/Media/BlockRevealImageView.swift +++ b/bitchat/Views/Media/BlockRevealImageView.swift @@ -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) } } diff --git a/bitchat/Views/Media/VoiceNoteView.swift b/bitchat/Views/Media/VoiceNoteView.swift index 9b18e498..50828430 100644 --- a/bitchat/Views/Media/VoiceNoteView.swift +++ b/bitchat/Views/Media/VoiceNoteView.swift @@ -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)