diff --git a/Localizable.xcstrings b/Localizable.xcstrings
index e585534f..1619558a 100644
--- a/Localizable.xcstrings
+++ b/Localizable.xcstrings
@@ -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" : {
diff --git a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift
index c9fab38a..a6f232fd 100644
--- a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift
+++ b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift
@@ -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))
diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 56.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 56.xcdatamodel/contents
index 38fd1242..c5760a62 100644
--- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 56.xcdatamodel/contents
+++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 56.xcdatamodel/contents
@@ -157,6 +157,8 @@
+
+
@@ -168,6 +170,7 @@
+
diff --git a/Meshtastic/Views/Messages/ChannelMessageRow.swift b/Meshtastic/Views/Messages/ChannelMessageRow.swift
index eb0f2a9f..8a02a80f 100644
--- a/Meshtastic/Views/Messages/ChannelMessageRow.swift
+++ b/Meshtastic/Views/Messages/ChannelMessageRow.swift
@@ -23,16 +23,16 @@ struct ChannelMessageRow: View {
Int64(preferredPeripheralNum) == message.fromUser?.num
}
- init(message: MessageEntity,
- allMessages: FetchedResults,
- previousMessage: MessageEntity?,
- preferredPeripheralNum: Int,
- channel: ChannelEntity,
- replyMessageId: Binding,
- messageFieldFocused: FocusState.Binding,
- messageToHighlight: Binding,
- scrollView: ScrollViewProxy,
- onInteractionComplete: @escaping () -> Void) {
+ init(message: MessageEntity,
+ allMessages: FetchedResults,
+ previousMessage: MessageEntity?,
+ preferredPeripheralNum: Int,
+ channel: ChannelEntity,
+ replyMessageId: Binding,
+ messageFieldFocused: FocusState.Binding,
+ messageToHighlight: Binding,
+ 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")
diff --git a/Meshtastic/Views/Messages/MessageContextMenuItems.swift b/Meshtastic/Views/Messages/MessageContextMenuItems.swift
index 8c5a301b..a97f3801 100644
--- a/Meshtastic/Views/Messages/MessageContextMenuItems.swift
+++ b/Meshtastic/Views/Messages/MessageContextMenuItems.swift
@@ -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)
diff --git a/Meshtastic/Views/Messages/MessageText.swift b/Meshtastic/Views/Messages/MessageText.swift
index 7427e14a..6343b91a 100644
--- a/Meshtastic/Views/Messages/MessageText.swift
+++ b/Meshtastic/Views/Messages/MessageText.swift
@@ -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 }
diff --git a/Meshtastic/Views/Messages/UserMessageRow.swift b/Meshtastic/Views/Messages/UserMessageRow.swift
index d469462b..9f5f50eb 100644
--- a/Meshtastic/Views/Messages/UserMessageRow.swift
+++ b/Meshtastic/Views/Messages/UserMessageRow.swift
@@ -28,15 +28,15 @@ struct UserMessageRow: View {
}
init(message: MessageEntity,
- allMessages: [MessageEntity],
- previousMessage: MessageEntity?,
- preferredPeripheralNum: Int,
- user: UserEntity,
- replyMessageId: Binding,
- messageFieldFocused: FocusState.Binding,
- messageToHighlight: Binding,
- scrollView: ScrollViewProxy,
- onInteractionComplete: @escaping () -> Void) {
+ allMessages: [MessageEntity],
+ previousMessage: MessageEntity?,
+ preferredPeripheralNum: Int,
+ user: UserEntity,
+ replyMessageId: Binding,
+ messageFieldFocused: FocusState.Binding,
+ messageToHighlight: Binding,
+ 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))
diff --git a/Meshtastic/Views/Settings/AppSettings.swift b/Meshtastic/Views/Settings/AppSettings.swift
index 495a2910..a9f0ad53 100644
--- a/Meshtastic/Views/Settings/AppSettings.swift
+++ b/Meshtastic/Views/Settings/AppSettings.swift
@@ -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)
}