Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -4314,6 +4314,9 @@
}
}
},
"%lld unread" : {
"comment" : "VoiceOver: number of unread messages in a channel"
},
"%lld%%" : {
"localizations" : {
"da" : {
Expand Down Expand Up @@ -20195,6 +20198,9 @@
}
}
},
"Channels, %lld unread" : {
"comment" : "VoiceOver: Channels section with unread message count"
},
"Chat-dominated: %@" : {
"comment" : "A label that indicates which presets had more messages exchanged.",
"isCommentAutoGenerated" : true,
Expand Down Expand Up @@ -30709,6 +30715,9 @@
}
}
},
"Direct Messages, %lld unread" : {
"comment" : "VoiceOver: Direct Messages section with unread message count"
},
"Direct Response" : {
"localizations" : {
"uk" : {
Expand Down Expand Up @@ -57394,6 +57403,9 @@
}
}
},
"Message from %@: %@" : {
"comment" : "VoiceOver: label for a received chat message. First value is the sender, second is the message text"
},
"Message status" : {
"comment" : "VoiceOver label for the message delivery status button"
},
Expand Down Expand Up @@ -58659,6 +58671,9 @@
}
}
},
"Muted" : {
"comment" : "VoiceOver: channel notifications are muted"
},
"NTP Server" : {
"localizations" : {
"uk" : {
Expand Down Expand Up @@ -73762,6 +73777,9 @@
}
}
},
"Reaction %@ from %@" : {
"comment" : "VoiceOver: a single emoji reaction and who sent it. First value is the emoji, second is the sender"
},
"Read and reply to Meshtastic channel and direct messages directly from your car's display using CarPlay." : {
"comment" : "A description of how to use CarPlay with Meshtastic.",
"isCommentAutoGenerated" : true,
Expand Down Expand Up @@ -76262,6 +76280,9 @@
}
}
},
"Replying to: %@" : {
"comment" : "VoiceOver: button that jumps to the quoted message being replied to. %@ is the quoted text"
},
"Request Legacy Admin: %@" : {
"localizations" : {
"da" : {
Expand Down Expand Up @@ -106317,6 +106338,9 @@
}
}
},
"Verified" : {
"comment" : "VoiceOver: message signature was cryptographically verified"
},
"Verified automatically" : {
"localizations" : {
"en" : {
Expand Down Expand Up @@ -110056,6 +110080,9 @@
}
}
},
"You sent: %@" : {
"comment" : "VoiceOver: label for a message you sent. %@ is the message text"
},
"Your MQTT Server must support TLS." : {
"localizations" : {
"da" : {
Expand Down
8 changes: 7 additions & 1 deletion Meshtastic/Views/Messages/ChannelList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ struct ChannelList: View {
NavigationLink(value: channel) {
let mostRecent = channel.mostRecentPrivateMessage
let hasMessages = mostRecent != nil
let hasUnreadMessages = hasMessages && (channel.unreadMessages > 0)
// `channel.unreadMessages` runs a FetchRequest; fetch once and reuse for both the
// indicator and its VoiceOver label instead of hitting Core Data twice per row.
let unreadCount = channel.unreadMessages
let hasUnreadMessages = hasMessages && (unreadCount > 0)
let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 ))))
let lastMessageDay = Calendar.current.dateComponents([.day], from: lastMessageTime).day ?? 0
let currentDay = Calendar.current.dateComponents([.day], from: Date()).day ?? 0
Expand All @@ -59,6 +62,8 @@ struct ChannelList: View {
.foregroundColor(.accentColor)
.brightness(0.2)
}
.accessibilityHidden(!hasUnreadMessages)
.accessibilityLabel(String(localized: "\(unreadCount) unread", comment: "VoiceOver: number of unread messages in a channel"))
CircleText(text: String(channel.index), color: .accentColor)
.brightness(0.2)

Expand Down Expand Up @@ -102,6 +107,7 @@ struct ChannelList: View {
}
if channel.mute {
Image(systemName: "bell.slash")
.accessibilityLabel(String(localized: "Muted", comment: "VoiceOver: channel notifications are muted"))
}
}

Expand Down
29 changes: 28 additions & 1 deletion Meshtastic/Views/Messages/ChannelMessageRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,30 @@ struct ChannelMessageRow: View {
private var isCurrentUser: Bool {
Int64(preferredPeripheralNum) == message.fromUser?.num
}


/// A single, natural-language description of the message bubble so VoiceOver reads one element
/// (sender + text + timestamp + security state) instead of separate fragments. Delivery/read
/// status stays its own labeled element (MessageDeliveryStatusLabel) directly after.
private var messageAccessibilityLabel: String {
let text = message.displayedPayload
let time = message.timestamp.formatted(date: .abbreviated, time: .shortened)
var parts: [String]
if isCurrentUser {
parts = [String(localized: "You sent: \(text)", comment: "VoiceOver: label for a message you sent. %@ is the message text")]
} else {
let sender = message.fromUser?.longName ?? "Unknown".localized
parts = [String(localized: "Message from \(sender): \(text)", comment: "VoiceOver: label for a received message. First value is the sender, second is the message text")]
}
parts.append(time)
if (message.pkiEncrypted && message.realACK) || (!isCurrentUser && message.pkiEncrypted) {
parts.append(String(localized: "Encrypted", comment: "VoiceOver: message is end-to-end encrypted"))
}
if message.xeddsaSigned {
parts.append(String(localized: "Verified", comment: "VoiceOver: message signature was cryptographically verified"))
}
return parts.joined(separator: ", ")
}

init(message: MessageEntity,
replyMessage: MessageEntity?,
tapbacks: [MessageEntity],
Expand Down Expand Up @@ -110,6 +133,7 @@ struct ChannelMessageRow: View {
.symbolRenderingMode(.hierarchical).imageScale(.large)
.foregroundColor(.accentColor).padding(.trailing)
}
.accessibilityLabel(String(localized: "Replying to: \(replyMessage?.displayedPayload ?? "EMPTY MESSAGE")", comment: "VoiceOver: button that jumps to the quoted message being replied to. %@ is the quoted text"))
if !isCurrentUser { Spacer(minLength: 50) }
}
}
Expand All @@ -133,6 +157,7 @@ struct ChannelMessageRow: View {
if !isCurrentUser && message.fromUser != nil {
Text("\(message.fromUser?.longName ?? "Unknown".localized ) (\(message.fromUser?.userId ?? "?"))")
.font(.caption).foregroundColor(.gray).offset(y: 8)
.accessibilityHidden(true) // Folded into the message bubble's combined label
}

// Message Bubble
Expand All @@ -147,6 +172,8 @@ struct ChannelMessageRow: View {
} onTapback: {
onTapback(message)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(messageAccessibilityLabel)

if let deliveryStatus, deliveryStatus.canRetry {
RetryButton(
Expand Down
8 changes: 8 additions & 0 deletions Meshtastic/Views/Messages/Messages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ struct Messages: View {
.alignmentGuide(.listRowSeparatorLeading) {
$0[.leading]
}
.accessibilityElement(children: .combine)
.accessibilityLabel(unreadChannelMessages > 0
? String(localized: "Channels, \(unreadChannelMessages) unread", comment: "VoiceOver: Channels section with unread message count")
: String(localized: "Channels", comment: "VoiceOver: Channels section"))
NavigationLink(value: MessagesNavigationState.directMessages()) {
Spacer()
Label {
Expand All @@ -97,6 +101,10 @@ struct Messages: View {
.alignmentGuide(.listRowSeparatorLeading) {
$0[.leading]
}
.accessibilityElement(children: .combine)
.accessibilityLabel(unreadDirectMessages > 0
? String(localized: "Direct Messages, \(unreadDirectMessages) unread", comment: "VoiceOver: Direct Messages section with unread message count")
: String(localized: "Direct Messages", comment: "VoiceOver: Direct Messages section"))
Spacer()
TipView(MessagesTip(), arrowEdge: .top)
.tipViewStyle(PersistentTipStyle())
Expand Down
2 changes: 2 additions & 0 deletions Meshtastic/Views/Messages/TapbackResponses.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ struct TapbackResponses: View {
.foregroundColor(.gray)
.fixedSize()
}
.accessibilityElement(children: .combine)
.accessibilityLabel(String(localized: "Reaction \(tapback.messagePayload ?? "") from \(tapback.fromUser?.shortName ?? "?")", comment: "VoiceOver: a single emoji reaction and who sent it. First value is the emoji, second is the sender"))
}
}
.padding(10)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct AlertButton: View {
.foregroundColor(compact ? .primary : .accentColor)
}
.frame(minWidth: compact ? 36 : nil, minHeight: compact ? 36 : nil)
.accessibilityLabel(String(localized: "Alert", comment: "VoiceOver: sends an alert bell to the conversation"))
}
}

Expand Down
29 changes: 28 additions & 1 deletion Meshtastic/Views/Messages/UserMessageRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,30 @@ struct UserMessageRow: View {
private var isCurrentUser: Bool {
Int64(preferredPeripheralNum) == message.fromUser?.num
}


/// A single, natural-language description of the message bubble so VoiceOver reads one element
/// (sender + text + timestamp + security state) instead of separate fragments. Delivery/read
/// status stays its own labeled element (MessageDeliveryStatusLabel) directly after.
private var messageAccessibilityLabel: String {
let text = message.displayedPayload
let time = message.timestamp.formatted(date: .abbreviated, time: .shortened)
var parts: [String]
if isCurrentUser {
parts = [String(localized: "You sent: \(text)", comment: "VoiceOver: label for a message you sent. %@ is the message text")]
} else {
let sender = message.fromUser?.longName ?? "Unknown".localized
parts = [String(localized: "Message from \(sender): \(text)", comment: "VoiceOver: label for a received message. First value is the sender, second is the message text")]
}
parts.append(time)
if (message.pkiEncrypted && message.realACK) || (!isCurrentUser && message.pkiEncrypted) {
parts.append(String(localized: "Encrypted", comment: "VoiceOver: message is end-to-end encrypted"))
}
if message.xeddsaSigned {
parts.append(String(localized: "Verified", comment: "VoiceOver: message signature was cryptographically verified"))
}
return parts.joined(separator: ", ")
}

init(
message: MessageEntity,
replyMessage: MessageEntity?,
Expand Down Expand Up @@ -115,6 +138,7 @@ struct UserMessageRow: View {
.padding(10)
.overlay(RoundedRectangle(cornerRadius: 18).stroke(Color.blue, lineWidth: 0.5))
}
.accessibilityLabel(String(localized: "Replying to: \(replyMessage?.displayedPayload ?? "EMPTY MESSAGE")", comment: "VoiceOver: button that jumps to the quoted message being replied to. %@ is the quoted text"))
if !isCurrentUser { Spacer(minLength: 50) }
}
}
Expand All @@ -138,6 +162,7 @@ struct UserMessageRow: View {
if !isCurrentUser && message.fromUser != nil {
Text("\(message.fromUser?.longName ?? "Unknown".localized ) (\(message.fromUser?.userId ?? "?"))")
.font(.caption).foregroundColor(.gray).offset(y: 8)
.accessibilityHidden(true) // Folded into the message bubble's combined label
}

// Message Bubble
Expand All @@ -152,6 +177,8 @@ struct UserMessageRow: View {
} onTapback: {
onTapback(message)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(messageAccessibilityLabel)

if let deliveryStatus, deliveryStatus.canRetry {
RetryButton(message: message, destination: .user(user), status: deliveryStatus)
Expand Down
Loading