Message translation (#1656)

* Add deep link documentation to README (#1655)

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/df28c94e-7e3d-44fc-8264-6ae1b875fb23

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* message translation core data version to match 2.7.10

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
Garth Vander Houwen 2026-04-05 21:38:10 -07:00 committed by GitHub
parent fab2a91300
commit 7fe81fa8a8
8 changed files with 187 additions and 26 deletions

View file

@ -11421,6 +11421,10 @@
}
}
},
"Clear Translation" : {
"comment" : "A button",
"isCommentAutoGenerated" : true
},
"Client" : {
"extractionState" : "stale",
"localizations" : {
@ -50644,6 +50648,14 @@
}
}
},
"Show Original" : {
"comment" : "A label for a button that shows the original message instead of the translation.",
"isCommentAutoGenerated" : true
},
"Show Translation" : {
"comment" : "A label for a button that toggles whether the translation of a message is shown.",
"isCommentAutoGenerated" : true
},
"Show Waypoints" : {
"extractionState" : "stale",
"localizations" : {
@ -56940,6 +56952,10 @@
}
}
},
"Translate" : {
"comment" : "A button to translate a message.",
"isCommentAutoGenerated" : true
},
"Transmit data (txd) GPIO pin" : {
"localizations" : {
"da" : {

View file

@ -12,6 +12,24 @@ import MapKit
import SwiftUI
extension MessageEntity {
var hasTranslatedPayload: Bool {
!(messagePayloadTranslated?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
}
var displayedPayload: String {
if showTranslatedMessage, hasTranslatedPayload {
return messagePayloadTranslated ?? messagePayload ?? "EMPTY MESSAGE"
}
return messagePayload ?? "EMPTY MESSAGE"
}
var displayedMarkdownPayload: String {
if showTranslatedMessage, hasTranslatedPayload {
return messagePayloadTranslatedMarkdown ?? messagePayloadTranslated ?? messagePayload ?? "EMPTY MESSAGE"
}
return messagePayloadMarkdown ?? messagePayload ?? "EMPTY MESSAGE"
}
var timestamp: Date {
let time = messageTimestamp
return Date(timeIntervalSince1970: TimeInterval(time))

View file

@ -157,6 +157,8 @@
<attribute name="messageId" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="messagePayload" optional="YES" attributeType="String" defaultValueString=""/>
<attribute name="messagePayloadMarkdown" optional="YES" attributeType="String"/>
<attribute name="messagePayloadTranslated" optional="YES" attributeType="String"/>
<attribute name="messagePayloadTranslatedMarkdown" optional="YES" attributeType="String"/>
<attribute name="messageTimestamp" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="pkiEncrypted" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="portNum" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
@ -168,6 +170,7 @@
<attribute name="relays" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="replyID" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="showTranslatedMessage" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<relationship name="fromUser" optional="YES" maxCount="1" deletionRule="Nullify" ordered="YES" destinationEntity="UserEntity" inverseName="sentMessages" inverseEntity="UserEntity"/>
<relationship name="toUser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserEntity" inverseName="receivedMessages" inverseEntity="UserEntity"/>

View file

@ -23,16 +23,16 @@ struct ChannelMessageRow: View {
Int64(preferredPeripheralNum) == message.fromUser?.num
}
init(message: MessageEntity,
allMessages: FetchedResults<MessageEntity>,
previousMessage: MessageEntity?,
preferredPeripheralNum: Int,
channel: ChannelEntity,
replyMessageId: Binding<Int64>,
messageFieldFocused: FocusState<Bool>.Binding,
messageToHighlight: Binding<Int64>,
scrollView: ScrollViewProxy,
onInteractionComplete: @escaping () -> Void) {
init(message: MessageEntity,
allMessages: FetchedResults<MessageEntity>,
previousMessage: MessageEntity?,
preferredPeripheralNum: Int,
channel: ChannelEntity,
replyMessageId: Binding<Int64>,
messageFieldFocused: FocusState<Bool>.Binding,
messageToHighlight: Binding<Int64>,
scrollView: ScrollViewProxy,
onInteractionComplete: @escaping () -> Void) {
// Initialize ObservedObject with the concrete instance
self._message = ObservedObject(initialValue: message)
self.allMessages = allMessages
@ -80,7 +80,7 @@ struct ChannelMessageRow: View {
}
}
} label: {
Text(messageReply?.messagePayload ?? "EMPTY MESSAGE").foregroundColor(.accentColor).font(.caption2)
Text(messageReply?.displayedPayload ?? "EMPTY MESSAGE").foregroundColor(.accentColor).font(.caption2)
.padding(10)
.overlay(RoundedRectangle(cornerRadius: 18).stroke(Color.blue, lineWidth: 0.5))
Image(systemName: "arrowshape.turn.up.left.fill")

View file

@ -12,7 +12,13 @@ struct MessageContextMenuItems: View {
@Binding var isShowingDeleteConfirmation: Bool
@Binding var isShowingTapbackInput: Bool
let onReply: () -> Void
@State var relayDisplay: String? = nil
let canTranslate: Bool
let hasTranslatedText: Bool
let isShowingTranslatedText: Bool
let onTranslate: () -> Void
let onToggleTranslatedText: () -> Void
let onClearTranslation: () -> Void
@State var relayDisplay: String?
var body: some View {
VStack {
@ -42,6 +48,25 @@ struct MessageContextMenuItems: View {
Image(systemName: "arrowshape.turn.up.left")
}
if canTranslate {
Button(action: onTranslate) {
Text("Translate")
Image(systemName: "translate")
}
}
if hasTranslatedText {
Button(action: onToggleTranslatedText) {
Text(isShowingTranslatedText ? "Show Original" : "Show Translation")
Image(systemName: isShowingTranslatedText ? "text.bubble" : "globe")
}
Button(role: .destructive, action: onClearTranslation) {
Text("Clear Translation")
Image(systemName: "trash")
}
}
Button {
UIPasteboard.general.string = message.messagePayload
} label: {
@ -56,6 +81,7 @@ struct MessageContextMenuItems: View {
let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date())
// Compute a relay display string if relayNode is present
VStack {
Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))")
.foregroundColor(.gray)

View file

@ -2,6 +2,9 @@ import MeshtasticProtobufs
import OSLog
import SwiftUI
import DatadogSessionReplay
#if !targetEnvironment(macCatalyst)
import Translation
#endif
struct MessageText: View {
static let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */
@ -27,6 +30,7 @@ struct MessageText: View {
// State for handling channel URL sheet
@State private var saveChannelLink: SaveChannelLinkData?
@State private var isShowingDeleteConfirmation = false
@State private var isShowingTranslationPresentation = false
@State private var tapbackText = ""
@FocusState private var isTapbackInputFocused: Bool
@ -58,9 +62,54 @@ struct MessageText: View {
}
}
private var sourceMessageText: String {
message.messagePayload ?? "EMPTY MESSAGE"
}
private var hasTranslatedText: Bool { message.hasTranslatedPayload }
private var isShowingTranslatedText: Bool {
message.showTranslatedMessage && hasTranslatedText
}
private var canTranslate: Bool {
guard !sourceMessageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
#if targetEnvironment(macCatalyst)
return false
#else
if #available(iOS 17.4, macOS 14.4, *) {
return true
}
return false
#endif
}
private var messageContent: some View {
let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE"))
return Text(markdownText)
#if !targetEnvironment(macCatalyst)
if #available(iOS 17.4, macOS 14.4, *), canTranslate {
return AnyView(
baseMessageContent
.translationPresentation(
isPresented: $isShowingTranslationPresentation,
text: sourceMessageText,
attachmentAnchor: .rect(.bounds),
arrowEdge: .top,
replacementAction: { replacement in
saveTranslatedText(replacement)
}
)
)
}
#endif
return AnyView(baseMessageContent)
}
private var baseMessageContent: some View {
let markdownText = LocalizedStringKey(message.displayedMarkdownPayload)
return Group {
Text(markdownText)
}
.tint(Self.linkBlue)
.padding(.vertical, 10)
.padding(.horizontal, 8)
@ -89,7 +138,13 @@ struct MessageText: View {
get: { isTapbackInputFocused },
set: { isTapbackInputFocused = $0 }
),
onReply: onReply
onReply: onReply,
canTranslate: canTranslate,
hasTranslatedText: hasTranslatedText,
isShowingTranslatedText: isShowingTranslatedText,
onTranslate: { isShowingTranslationPresentation = true },
onToggleTranslatedText: { toggleTranslatedText() },
onClearTranslation: { clearTranslation() }
)
}
}
@ -131,6 +186,14 @@ struct MessageText: View {
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
.offset(x: 20, y: -20)
}
if isShowingTranslatedText {
Image(systemName: "translate")
.font(.system(size: 20))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
.foregroundStyle(Color.blue)
.symbolRenderingMode(.hierarchical)
.offset(x: 38, y: 8)
}
}
private func handleURL(_ url: URL) -> OpenURLAction.Result {
@ -170,6 +233,41 @@ struct MessageText: View {
Logger.data.error("Failed to delete message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
}
private func saveTranslatedText(_ replacement: String) {
message.messagePayloadTranslated = replacement
message.messagePayloadTranslatedMarkdown = generateMessageMarkdown(message: replacement)
message.showTranslatedMessage = true
do {
try context.save()
} catch {
Logger.data.error("Failed to save translated message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
}
private func toggleTranslatedText() {
guard hasTranslatedText else { return }
message.showTranslatedMessage.toggle()
do {
try context.save()
} catch {
Logger.data.error("Failed to toggle translated message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
}
private func clearTranslation() {
message.messagePayloadTranslated = nil
message.messagePayloadTranslatedMarkdown = nil
message.showTranslatedMessage = false
do {
try context.save()
} catch {
Logger.data.error("Failed to clear translated message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
}
private func processTapback() {
guard !tapbackText.isEmpty else { return }

View file

@ -28,15 +28,15 @@ struct UserMessageRow: View {
}
init(message: MessageEntity,
allMessages: [MessageEntity],
previousMessage: MessageEntity?,
preferredPeripheralNum: Int,
user: UserEntity,
replyMessageId: Binding<Int64>,
messageFieldFocused: FocusState<Bool>.Binding,
messageToHighlight: Binding<Int64>,
scrollView: ScrollViewProxy,
onInteractionComplete: @escaping () -> Void) {
allMessages: [MessageEntity],
previousMessage: MessageEntity?,
preferredPeripheralNum: Int,
user: UserEntity,
replyMessageId: Binding<Int64>,
messageFieldFocused: FocusState<Bool>.Binding,
messageToHighlight: Binding<Int64>,
scrollView: ScrollViewProxy,
onInteractionComplete: @escaping () -> Void) {
// Initialize ObservedObject with the concrete instance
self._message = ObservedObject(initialValue: message)
self.allMessages = allMessages
@ -88,7 +88,7 @@ struct UserMessageRow: View {
Image(systemName: "arrowshape.turn.up.left.fill")
.symbolRenderingMode(.hierarchical).imageScale(.large)
.foregroundColor(.accentColor).padding(.leading)
Text(messageReply?.messagePayload ?? "EMPTY MESSAGE").foregroundColor(.accentColor).font(.caption2)
Text(messageReply?.displayedPayload ?? "EMPTY MESSAGE").foregroundColor(.accentColor).font(.caption2)
}
.padding(10)
.overlay(RoundedRectangle(cornerRadius: 18).stroke(Color.blue, lineWidth: 0.5))

View file

@ -97,7 +97,7 @@ struct AppSettings: View {
}
#endif
}
.onChange(of: usageDataAndCrashReporting) { oldUsageDataAndCrashReporting, newUsageDataAndCrashReporting in
.onChange(of: usageDataAndCrashReporting) { _, newUsageDataAndCrashReporting in
if !newUsageDataAndCrashReporting {
Datadog.set(trackingConsent: .notGranted)
}