mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
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:
parent
fab2a91300
commit
7fe81fa8a8
8 changed files with 187 additions and 26 deletions
|
|
@ -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" : {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue