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