mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
Make private-message delivery status legible and accessible (#1356)
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Fable 5
parent
66536063ca
commit
9ccff9cce4
@@ -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" : {
|
||||
|
||||
@@ -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"))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = []
|
||||
@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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user