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
ff7fbda1c7
commit
316fc48737
9 changed files with 263 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)
|
||||
}
|
||||
|
|
|
|||
76
README.md
76
README.md
|
|
@ -46,6 +46,82 @@ The last two major operating system versions are supported on iOS, iPadOS and ma
|
|||
```
|
||||
2. Build, test, and commit the changes.
|
||||
|
||||
## Deep Links
|
||||
|
||||
The app supports deep links using the `meshtastic:///` URL scheme, for use with shortcuts, intents, and web pages.
|
||||
|
||||
### Messages
|
||||
|
||||
| URL | Description |
|
||||
|-----|-------------|
|
||||
| `meshtastic:///messages` | Messages tab |
|
||||
| `meshtastic:///messages?channelId={channelId}&messageId={messageId}` | Channel messages (`messageId` is optional) |
|
||||
| `meshtastic:///messages?userNum={userNum}&messageId={messageId}` | Direct messages (`messageId` is optional) |
|
||||
|
||||
### Connect
|
||||
|
||||
| URL | Description |
|
||||
|-----|-------------|
|
||||
| `meshtastic:///connect` | Connect tab |
|
||||
|
||||
### Nodes
|
||||
|
||||
| URL | Description |
|
||||
|-----|-------------|
|
||||
| `meshtastic:///nodes` | Nodes tab |
|
||||
| `meshtastic:///nodes?nodenum={nodenum}` | Selected node |
|
||||
|
||||
### Mesh Map
|
||||
|
||||
| URL | Description |
|
||||
|-----|-------------|
|
||||
| `meshtastic:///map` | Map tab |
|
||||
| `meshtastic:///map?nodenum={nodenum}` | Node on map |
|
||||
| `meshtastic:///map?waypointId={waypointId}` | Waypoint on map |
|
||||
|
||||
### Settings
|
||||
|
||||
Each settings item has an associated deep link. No parameters are supported for settings URLs.
|
||||
|
||||
| URL | Description |
|
||||
|-----|-------------|
|
||||
| `meshtastic:///settings/about` | About Meshtastic |
|
||||
| `meshtastic:///settings/appSettings` | App Settings |
|
||||
| `meshtastic:///settings/routes` | Routes |
|
||||
| `meshtastic:///settings/routeRecorder` | Route Recorder |
|
||||
| **Radio Config** | |
|
||||
| `meshtastic:///settings/lora` | LoRa Config |
|
||||
| `meshtastic:///settings/channels` | Channels |
|
||||
| `meshtastic:///settings/security` | Security Config |
|
||||
| `meshtastic:///settings/shareQRCode` | Share QR Code |
|
||||
| **Device Config** | |
|
||||
| `meshtastic:///settings/user` | User Config |
|
||||
| `meshtastic:///settings/bluetooth` | Bluetooth Config |
|
||||
| `meshtastic:///settings/device` | Device Config |
|
||||
| `meshtastic:///settings/display` | Display Config |
|
||||
| `meshtastic:///settings/network` | Network Config |
|
||||
| `meshtastic:///settings/position` | Position Config |
|
||||
| `meshtastic:///settings/power` | Power Config |
|
||||
| **Module Config** | |
|
||||
| `meshtastic:///settings/ambientLighting` | Ambient Lighting |
|
||||
| `meshtastic:///settings/cannedMessages` | Canned Messages |
|
||||
| `meshtastic:///settings/detectionSensor` | Detection Sensor |
|
||||
| `meshtastic:///settings/externalNotification` | External Notification |
|
||||
| `meshtastic:///settings/mqtt` | MQTT |
|
||||
| `meshtastic:///settings/paxCounter` | Pax Counter |
|
||||
| `meshtastic:///settings/rangeTest` | Range Test |
|
||||
| `meshtastic:///settings/ringtone` | Ringtone |
|
||||
| `meshtastic:///settings/serial` | Serial |
|
||||
| `meshtastic:///settings/storeAndForward` | Store & Forward |
|
||||
| `meshtastic:///settings/telemetry` | Telemetry |
|
||||
| **TAK** | |
|
||||
| `meshtastic:///settings/tak` | TAK Config |
|
||||
| **Logging** | |
|
||||
| `meshtastic:///settings/debugLogs` | Debug Logs |
|
||||
| **Developers** | |
|
||||
| `meshtastic:///settings/appFiles` | App Files |
|
||||
| `meshtastic:///settings/firmwareUpdates` | Firmware Updates |
|
||||
|
||||
## Release Process
|
||||
|
||||
For more information on how a new release of Meshtastic is managed, please refer to [RELEASING.md](./RELEASING.md)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue