From 7668a7a7ae9eeb35ace627553841ff56155eb246 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Tue, 28 Oct 2025 06:19:12 -0700 Subject: [PATCH] Show who relayed messages (#1486) * Add identification for node that relayed text messages and add count for ammount of relayers of your message * Ack Relays --- Localizable.xcstrings | 18 +++++++ .../CoreData/MessageEntityExtension.swift | 35 +++++++++++++ Meshtastic/Helpers/MeshPackets.swift | 7 +++ .../contents | 4 +- .../Messages/MessageContextMenuItems.swift | 50 ++++++++++++++----- 5 files changed, 100 insertions(+), 14 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 6f4a50b6..44aa74bb 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -28480,6 +28480,7 @@ } }, "Received Ack" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -28542,8 +28543,12 @@ } } } + }, + "Received Ack: %@" : { + }, "Recipient Ack" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -28606,6 +28611,9 @@ } } } + }, + "Recipient Ack: %@" : { + }, "Recording route" : { "localizations" : { @@ -28789,6 +28797,16 @@ } } }, + "Relayed by %d %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Relayed by %1$d %2$@" + } + } + } + }, "Release Notes" : { "localizations" : { "it" : { diff --git a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift index e7abb191..5c06ecf2 100644 --- a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift @@ -38,4 +38,39 @@ extension MessageEntity { } return false // First message will have no timestamp } + + func relayDisplay() -> String? { + + guard self.relayNode != 0 else { return nil } + let context = PersistenceController.shared.container.viewContext + + let relaySuffix = Int64(self.relayNode & 0xFF) + let request: NSFetchRequest = UserEntity.fetchRequest() + request.predicate = NSPredicate(format: "(num & 0xFF) == %lld", relaySuffix) + + do { + let users = try context.fetch(request) + + // If exactly one match is found, return its name + if users.count == 1, let name = users.first?.longName, !name.isEmpty { + return "\(name)" + } + + // If no exact match, find the node with the smallest hopsAway + if let closestNode = users.min(by: { lhs, rhs in + guard let lhsHops = lhs.userNode?.hopsAway, let rhsHops = rhs.userNode?.hopsAway else { + return false + } + return lhsHops < rhsHops + }), let name = closestNode.longName, !name.isEmpty { + return "\(name)" + } + + // Fallback to hex node number if no matches + return String(format: "Node 0x%02X", UInt32(self.relayNode & 0xFF)) + + } catch { + return String(format: "Node 0x%02X", UInt32(self.relayNode & 0xFF)) + } + } } diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index e3aa3252..1e68896b 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -624,6 +624,7 @@ func adminResponseAck (packet: MeshPacket, context: NSManagedObjectContext) { fetchedMessage[0].ackError = Int32(RoutingError.none.rawValue) fetchedMessage[0].receivedACK = true fetchedMessage[0].realACK = true + fetchedMessage[0].relayNode = Int64(packet.relayNode) fetchedMessage[0].ackSNR = packet.rxSnr if fetchedMessage[0].fromUser != nil { fetchedMessage[0].fromUser?.objectWillChange.send() @@ -699,9 +700,11 @@ func routingPacket (packet: MeshPacket, connectedNodeNum: Int64, context: NSMana fetchedMessage[0].realACK = true } } + fetchedMessage[0].relayNode = Int64(packet.relayNode) fetchedMessage[0].ackError = Int32(routingMessage.errorReason.rawValue) if routingMessage.errorReason == Routing.Error.none { fetchedMessage[0].receivedACK = true + fetchedMessage[0].relays += 1 } fetchedMessage[0].ackSNR = packet.rxSnr @@ -944,6 +947,9 @@ func textMessageAppPacket( } else { newMessage.messageTimestamp = Int32(Date().timeIntervalSince1970) } + if packet.relayNode != 0 { + newMessage.relayNode = Int64(packet.relayNode) + } newMessage.receivedACK = false newMessage.snr = packet.rxSnr newMessage.rssi = packet.rxRssi @@ -983,6 +989,7 @@ func textMessageAppPacket( newMessage.pkiEncrypted = true newMessage.publicKey = packet.publicKey } + /// Check for key mismatch if let nodeKey = newMessage.fromUser?.publicKey { if newMessage.toUser != nil && packet.pkiEncrypted && !packet.publicKey.isEmpty { diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents index b5e4a81e..a6e5465f 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -164,6 +164,8 @@ + + diff --git a/Meshtastic/Views/Messages/MessageContextMenuItems.swift b/Meshtastic/Views/Messages/MessageContextMenuItems.swift index 33554de7..63104320 100644 --- a/Meshtastic/Views/Messages/MessageContextMenuItems.swift +++ b/Meshtastic/Views/Messages/MessageContextMenuItems.swift @@ -11,6 +11,7 @@ struct MessageContextMenuItems: View { let isCurrentUser: Bool @Binding var isShowingDeleteConfirmation: Bool let onReply: () -> Void + @State var relayDisplay: String? = nil var body: some View { VStack { @@ -19,6 +20,14 @@ struct MessageContextMenuItems: View { } Text("Channel") + Text(": \(message.channel)") } + .onAppear { + DispatchQueue.global(qos: .userInitiated).async { + let result = message.relayDisplay() + DispatchQueue.main.async { + relayDisplay = result + } + } + } Menu("Tapback") { ForEach(Tapbacks.allCases) { tb in @@ -59,12 +68,27 @@ struct MessageContextMenuItems: View { } Menu("Message Details") { + // Precompute values to avoid executing non-View code inside the ViewBuilder + let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp)) + let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp)) + let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date()) + + // Compute a relay display string if relayNode is present + + VStack { - let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp)) - Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))").foregroundColor(.gray) + Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))") + .foregroundColor(.gray) } - if !isCurrentUser && !(message.fromUser?.userNode?.viaMqtt ?? false) && message.fromUser?.userNode?.hopsAway ?? -1 == 0 { + if let relayDisplay { + let prefix = message.realACK ? "Ack Relay: " : "Relay: " + Text(prefix + relayDisplay) + .foregroundColor(relayDisplay.contains("Node ") ? .gray : .primary) + .font(relayDisplay.contains("Node ") ? .caption : .body) + } + + if !isCurrentUser && !(message.fromUser?.userNode?.viaMqtt ?? false) && message.fromUser?.userNode?.hopsAway ?? -1 == 0 { VStack { Text("SNR \(String(format: "%.2f", message.snr)) dB") Text("RSSI \(String(format: "%.2f", message.rssi)) dBm") @@ -74,29 +98,29 @@ struct MessageContextMenuItems: View { Text("Hops Away \(message.fromUser?.userNode?.hopsAway ?? 0)") } } + if message.relays != 0 && message.realACK == false { + Text("Relayed by \(message.relays) \(message.relays == 1 ? "node" : "nodes")") + } if isCurrentUser && message.receivedACK { VStack { - Text("Received Ack") + Text(": \(message.receivedACK ? "✔️" : "")") - Text("Recipient Ack") + Text(": \(message.realACK ? "✔️" : "")") + Text("Received Ack: \(message.receivedACK ? "✔️" : "")") + Text("Recipient Ack: \(message.realACK ? "✔️" : "")") } } else if isCurrentUser && message.ackError == 0 { - // Empty Error Text("Waiting") } else if isCurrentUser && message.ackError > 0 { let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) Text("\(ackErrorVal?.display ?? "Empty Ack Error")") .fixedSize(horizontal: false, vertical: true) } + if isCurrentUser { - VStack { - let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp)) - let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date()) - if ackDate >= sixMonthsAgo! { - Text("Ack Time: \(ackDate.formattedDate(format: MessageText.timeFormatString))") - .foregroundColor(.gray) - } + if let sixMonthsAgo, ackDate >= sixMonthsAgo { + Text("Ack Time: \(ackDate.formattedDate(format: MessageText.timeFormatString))") + .foregroundColor(.gray) } } + if message.ackSNR != 0 { VStack { Text("Ack SNR: \(String(format: "%.2f", message.ackSNR)) dB")