From 9ccff9cce4093a5b80cc184bdfb4b0b143b7899a Mon Sep 17 00:00:00 2001 From: Hot Pixel Group Date: Mon, 6 Jul 2026 00:58:25 -0700 Subject: [PATCH] Make private-message delivery status legible and accessible (#1356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make private-message delivery status legible and accessible The delivery indicator is the most stress-relevant signal in an off-grid messenger, but it is hard to read: - The status glyphs are 10pt icons whose only explanation is a `.help()` tooltip, which does not exist on iOS. - Delivered vs read is the same double-checkmark distinguished only by colour. - No case carries an accessibility label, so VoiceOver announces nothing. - Two failure reasons ("Not delivered", "Encryption failed") bypass the localized reason catalog and are hardcoded English. Changes (presentation only; the DeliveryStatus enum and the contract-tested `displayText` are untouched): - Add `DeliveryStatus.bitchatDescription`, a localized app-layer description, used as the macOS tooltip, a VoiceOver label on every status glyph, and — on iOS, where tooltips don't exist — a tap-to-reveal caption under the message. - Failure reasons stay visible as a red caption without a tap. - Read vs delivered is now legible without colour: read uses filled-circle checkmarks. - Route the two hardcoded failure reasons through the localized catalog. New strings are added source-language (en) only. * Show the failure reason on failed media messages too TextMessageView gained a visible red failure caption (the status glyph's .help() tooltip does not exist on iOS), but MediaMessageView still rendered the bare glyph — so a failed voice-note or image send showed only a 10pt red triangle with no reason on iOS. Add the same failure caption to media messages. * Collapse revealed delivery detail when the status changes A caption revealed while a message was "sending" stayed open and silently morphed through later statuses (sent, delivered, read). Reset showDeliveryDetail when the snapshotted DeliveryStatus changes. Co-Authored-By: Claude Fable 5 * Add tap-to-reveal delivery detail to media rows Media rows showed the same delivery glyphs as text rows but offered no way to explain them on iOS, where .help() tooltips don't exist. Mirror the text-row pattern: the glyph is now a button that reveals the localized status caption below the header, failure reasons stay visible without a tap, and the revealed caption collapses when the status advances. Co-Authored-By: Claude Fable 5 * Localize the remaining voice-note failure reasons ChatMediaTransferCoordinator still passed hardcoded English reasons into .failed(reason:), which now surface verbatim in the always-visible failure caption. Route them through String(localized:) under the existing content.delivery.reason.* convention. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Localizable.xcstrings | 84 +++++++++++++ bitchat/Services/BLE/BLEService.swift | 2 +- .../ChatMediaTransferCoordinator.swift | 4 +- .../ChatViewModelBootstrapper.swift | 2 +- .../Views/Components/DeliveryStatusView.swift | 115 ++++++++++-------- .../Views/Components/TextMessageView.swift | 43 ++++++- bitchat/Views/Media/MediaMessageView.swift | 40 +++++- 7 files changed, 228 insertions(+), 62 deletions(-) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index d7c842cd..64dec94c 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -8953,6 +8953,18 @@ } } }, + "content.accessibility.delivery_detail_hint" : { + "comment" : "Accessibility hint for the delivery status glyph explaining a tap reveals details", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tap to show delivery details" + } + } + } + }, "content.accessibility.encryption_status" : { "extractionState" : "manual", "localizations" : { @@ -16688,6 +16700,54 @@ } } }, + "content.delivery.reason.not_delivered" : { + "comment" : "Failure reason shown when the router gave up delivering a message", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "not delivered" + } + } + } + }, + "content.delivery.reason.encryption_failed" : { + "comment" : "Failure reason shown when a message could not be encrypted for the peer", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "encryption failed" + } + } + } + }, + "content.delivery.reason.voice_too_large" : { + "comment" : "Failure reason shown when a voice note exceeds the size limit", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "voice note too large" + } + } + } + }, + "content.delivery.reason.voice_send_failed" : { + "comment" : "Failure reason shown when a voice note could not be sent", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "voice note failed to send" + } + } + } + }, "content.delivery.reason.self" : { "extractionState" : "manual", "localizations" : { @@ -17404,6 +17464,30 @@ } } }, + "content.delivery.sending" : { + "comment" : "Delivery status description while a private message is being sent", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sending..." + } + } + } + }, + "content.delivery.sent" : { + "comment" : "Delivery status description for a sent but not yet confirmed private message", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sent — no delivery confirmation yet" + } + } + } + }, "content.header.people" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index a0f77ded..6147633b 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -2700,7 +2700,7 @@ extension BLEService { // Notify delegate of failure notifyUI { [weak self] in - self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: "Encryption failed"))) + self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer")))) } } } diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index ce51abe9..bf0009da 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -117,13 +117,13 @@ final class ChatMediaTransferCoordinator { try? FileManager.default.removeItem(at: url) await MainActor.run { [weak self] in guard let self else { return } - self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large") + self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit")) } } catch { SecureLogger.error("Voice note send failed: \(error)", category: .session) await MainActor.run { [weak self] in guard let self else { return } - self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note") + self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent")) } } } diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index e2615cf9..76b8d55c 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -100,7 +100,7 @@ private extension ChatViewModelBootstrapper { category: .session ) viewModel.conversations.setDeliveryStatus( - .failed(reason: "Not delivered"), + .failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")), forMessageID: messageID ) } diff --git a/bitchat/Views/Components/DeliveryStatusView.swift b/bitchat/Views/Components/DeliveryStatusView.swift index 563bd9aa..597e2409 100644 --- a/bitchat/Views/Components/DeliveryStatusView.swift +++ b/bitchat/Views/Components/DeliveryStatusView.swift @@ -9,6 +9,45 @@ import SwiftUI import BitFoundation +extension DeliveryStatus { + /// Localized, user-facing description of the status. Used for macOS + /// tooltips, the tap-to-reveal caption under a message, and VoiceOver — + /// the glyphs alone are unexplained 10pt icons. + var bitchatDescription: String { + switch self { + case .sending: + return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent") + case .sent: + return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message") + case .delivered(let nickname, _): + return String( + format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"), + locale: .current, + nickname + ) + case .read(let nickname, _): + return String( + format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"), + locale: .current, + nickname + ) + case .failed(let reason): + return String( + format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"), + locale: .current, + reason + ) + case .partiallyDelivered(let reached, let total): + return String( + format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"), + locale: .current, + reached, + total + ) + } + } +} + struct DeliveryStatusView: View { @ThemedPalette private var palette let status: DeliveryStatus @@ -19,56 +58,29 @@ struct DeliveryStatusView: View { private var secondaryTextColor: Color { palette.secondary } - private enum Strings { - static func delivered(to nickname: String) -> String { - String( - format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"), - locale: .current, - nickname - ) - } - - static func read(by nickname: String) -> String { - String( - format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"), - locale: .current, - nickname - ) - } - - static func failed(_ reason: String) -> String { - String( - format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"), - locale: .current, - reason - ) - } - - static func deliveredToMembers(_ reached: Int, _ total: Int) -> String { - String( - format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"), - locale: .current, - reached, - total - ) - } - } - // MARK: - Body - + var body: some View { + statusGlyph + .help(status.bitchatDescription) + .accessibilityElement(children: .ignore) + .accessibilityLabel(status.bitchatDescription) + } + + @ViewBuilder + private var statusGlyph: some View { switch status { case .sending: Image(systemName: "circle") .font(.bitchatSystem(size: 10)) .foregroundColor(secondaryTextColor.opacity(0.6)) - + case .sent: Image(systemName: "checkmark") .font(.bitchatSystem(size: 10)) .foregroundColor(secondaryTextColor.opacity(0.6)) - - case .delivered(let nickname, _): + + case .delivered: HStack(spacing: -2) { Image(systemName: "checkmark") .font(.bitchatSystem(size: 10)) @@ -76,24 +88,22 @@ struct DeliveryStatusView: View { .font(.bitchatSystem(size: 10)) } .foregroundColor(textColor.opacity(0.8)) - .help(Strings.delivered(to: nickname)) - - case .read(let nickname, _): - HStack(spacing: -2) { - Image(systemName: "checkmark") - .font(.bitchatSystem(size: 10, weight: .bold)) - Image(systemName: "checkmark") - .font(.bitchatSystem(size: 10, weight: .bold)) + + case .read: + // Filled variant so read vs delivered is legible without color. + HStack(spacing: 0) { + Image(systemName: "checkmark.circle.fill") + .font(.bitchatSystem(size: 9, weight: .bold)) + Image(systemName: "checkmark.circle.fill") + .font(.bitchatSystem(size: 9, weight: .bold)) } .foregroundColor(palette.accentBlue) - .help(Strings.read(by: nickname)) - - case .failed(let reason): + + case .failed: Image(systemName: "exclamationmark.triangle") .font(.bitchatSystem(size: 10)) .foregroundColor(Color.red.opacity(0.8)) - .help(Strings.failed(reason)) - + case .partiallyDelivered(let reached, let total): HStack(spacing: 1) { Image(systemName: "checkmark") @@ -102,7 +112,6 @@ struct DeliveryStatusView: View { .bitchatFont(size: 10) } .foregroundColor(secondaryTextColor.opacity(0.6)) - .help(Strings.deliveredToMembers(reached, total)) } } } diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 63346e26..ae99619c 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -24,6 +24,7 @@ struct TextMessageView: View { /// the enum makes the change visible to SwiftUI's structural diff. private let deliveryStatus: DeliveryStatus? @State private var expandedMessageIDs: Set = [] + @State private var showDeliveryDetail = false init(message: BitchatMessage) { self.message = message @@ -43,11 +44,41 @@ struct TextMessageView: View { .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) .frame(maxWidth: .infinity, alignment: .leading) - // Delivery status indicator for private messages + // Delivery status indicator for private messages. Tappable: + // .help() tooltips only exist on macOS, so iOS users get the + // explanation as a caption under the row instead. if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), let status = deliveryStatus { - DeliveryStatusView(status: status) - .padding(.leading, 4) + Button { + showDeliveryDetail.toggle() + } label: { + DeliveryStatusView(status: status) + .padding(.leading, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHint( + String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details") + ) + } + } + + // Failure reasons stay visible without a tap; other statuses + // reveal on demand. + if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), + let status = deliveryStatus { + if case .failed = status { + Text(verbatim: status.bitchatDescription) + .bitchatFont(size: 11) + .foregroundColor(Color.red.opacity(0.9)) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 2) + } else if showDeliveryDetail { + Text(verbatim: status.bitchatDescription) + .bitchatFont(size: 11) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 2) } } @@ -78,6 +109,12 @@ struct TextMessageView: View { .padding(.leading, 2) } } + // Collapse the revealed caption when the status advances (e.g. + // sending → sent → delivered) so a detail opened for one state + // doesn't linger and silently morph into another. + .onChange(of: deliveryStatus) { _ in + showDeliveryDetail = false + } } } diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index 8bee3bd2..0c34ed79 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -20,6 +20,7 @@ struct MediaMessageView: View { /// fields by identity, so without the snapshot a status-only change /// (send progress, delivered → read) would not re-render this row. private let deliveryStatus: DeliveryStatus? + @State private var showDeliveryDetail = false @Binding var imagePreviewURL: URL? @@ -40,10 +41,39 @@ struct MediaMessageView: View { Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) + // Delivery status indicator for private messages. Tappable: + // .help() tooltips only exist on macOS, so iOS users get the + // explanation as a caption under the row instead. if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), let status = deliveryStatus { - DeliveryStatusView(status: status) - .padding(.leading, 4) + Button { + showDeliveryDetail.toggle() + } label: { + DeliveryStatusView(status: status) + .padding(.leading, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHint( + String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details") + ) + } + } + + // Failure reasons stay visible without a tap; other statuses + // reveal on demand. + if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), + let status = deliveryStatus { + if case .failed = status { + Text(verbatim: status.bitchatDescription) + .bitchatFont(size: 11) + .foregroundColor(Color.red.opacity(0.9)) + .fixedSize(horizontal: false, vertical: true) + } else if showDeliveryDetail { + Text(verbatim: status.bitchatDescription) + .bitchatFont(size: 11) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) } } @@ -75,6 +105,12 @@ struct MediaMessageView: View { } } .padding(.vertical, 4) + // Collapse the revealed caption when the status advances (e.g. + // sending → sent → delivered) so a detail opened for one state + // doesn't linger and silently morph into another. + .onChange(of: deliveryStatus) { _ in + showDeliveryDetail = false + } } private func mediaSendState(for deliveryStatus: DeliveryStatus?) -> (isSending: Bool, progress: Double?, canCancel: Bool) {