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) {