From ad25342d884e8671456ef1cd49f6feee4e41ffad Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Fri, 17 Oct 2025 18:11:37 -0700 Subject: [PATCH 01/13] Bump version --- Meshtastic.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 63f334e5..7a4a216f 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -2094,7 +2094,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.5; + MARKETING_VERSION = 2.7.6; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -2129,7 +2129,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.5; + MARKETING_VERSION = 2.7.6; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -2161,7 +2161,7 @@ "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.5; + MARKETING_VERSION = 2.7.6; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2194,7 +2194,7 @@ "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.5; + MARKETING_VERSION = 2.7.6; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From 69c318a9e18ea7eec8e8ebc1e65cef767e62b326 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Fri, 17 Oct 2025 18:16:00 -0700 Subject: [PATCH 02/13] Message list performance fixes into 2.7.6 (#1475) * Remove extra want config call when adding a contact * App badge and unnecessary notification fixes (#1455) * - Fix for app badge not going to zero if a message arrives while you have that chat open - Fix for push notifications popping up when a message is received while that chat is open * Fix for cancelling notifications, works now. And scroll to bottom of conversation upon new message * Fix: Channels help grammer fix (#1452) * remove outdated TCP not available on Apple devices (#1450) * Update initial onboarding view * remove toggle gating for mac * Update crash reporting opt out in real time * Update onboarding text * Use mDNS text records for node name * TCP IP and port on the connection screen * Hide app icon chooser on mac * Infinite loop hang bugfixes and performance improvements for both `UserMessageList` and `ChannelMessageList` (#1465) * 2.7.5 Working Changes (#1460) * Remove extra want config call when adding a contact * App badge and unnecessary notification fixes (#1455) * - Fix for app badge not going to zero if a message arrives while you have that chat open - Fix for push notifications popping up when a message is received while that chat is open * Fix for cancelling notifications, works now. And scroll to bottom of conversation upon new message * Fix: Channels help grammer fix (#1452) * remove outdated TCP not available on Apple devices (#1450) * Update initial onboarding view * remove toggle gating for mac * Update crash reporting opt out in real time * Update onboarding text --------- Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com> Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com> * UserEntity: add mostRecentMessage and unreadMessages with early exit when lastMessage is nil, and fetch 1 row (not N) otherwise * UserList: replace 5 slow calls to user.messageList with new fast calls * NodeList: always put the connected node at the top of list (if it matches the node filters) * ChannelEntity: add faster mostRecentPrivateMessage and unreadMessages which fetch 1 row (not N) * ChannelList: replace 5 calls to channel.allPrivateMessage with new fast calls * Fix incorrect appState.unreadDirectMessages calculations * MyInfoEntity: also fix unreadMessages count here to be fast, and use it for appState.unreadChannelMessages * UserMessageList: use @FetchRequest to prevent the N^2 behavior that was happening in calls to allPrivateMessages * Refactor ChannelEntityExtension and MyInfoEntityExtension to be more similar to UserEntityExtension * Remove SwiftUI-infinite-loop-causing `.id(redrawTapbacksTrigger)` in ChannelMessageList and UserMessageList (duplicate row ids) * MyInfoEntityExtension: exclude emoji tapbacks (which never get marked as read anyway) from unread message count * Add SaveChannelLinkData so MessageText and MeshtasticApp can use .sheet(item: ...) and avoid infinite loop hang due to Binding rebuild * ChannelMessageList and UserMessageList: switch to stable messageId for ForEach SwiftUI row identity * ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification * ChannelMessageList and UserMessageList: scroll to bottom onFirstAppear * ChannelMessageList and UserMessageList: block spurious markMessagesAsRead when this View is not active --------- Co-authored-by: Garth Vander Houwen Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com> Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com> * message-list-performance: revert scrolling changes (#1472) * Revert e0f0b4a0f749d2e83946f2c1297e5c97c9fdf46e (ChannelMessageList and UserMessageList: scroll to bottom onFirstAppear) * Revert "ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification" This reverts commit ee1a7c44157eb6970ec2111fc1ac4d67a44a8238. --------- Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com> Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com> Co-authored-by: Jake-B Co-authored-by: Mike Robbins --- Localizable.xcstrings | 7 +++ .../AccessoryManager+MQTT.swift | 4 +- Meshtastic/Accessory/Protocols/Device.swift | 6 ++- .../Transports/TCP/TCPTransport.swift | 21 +++++++- .../CoreData/ChannelEntityExtension.swift | 36 ++++++++++--- .../CoreData/MyInfoEntityExtension.swift | 30 ++++++++--- .../CoreData/UserEntityExtension.swift | 44 ++++++++++++--- Meshtastic/Helpers/MeshPackets.swift | 7 ++- Meshtastic/MeshtasticApp.swift | 53 ++++++++---------- Meshtastic/Views/Connect/Connect.swift | 27 ++++++++-- Meshtastic/Views/Messages/ChannelList.swift | 13 +++-- .../Views/Messages/ChannelMessageList.swift | 26 +++++++-- Meshtastic/Views/Messages/MessageText.swift | 33 ++++-------- Meshtastic/Views/Messages/UserList.swift | 26 ++++----- .../Views/Messages/UserMessageList.swift | 54 ++++++++++++++----- .../Nodes/Helpers/ShareContactQRDialog.swift | 2 + Meshtastic/Views/Nodes/NodeList.swift | 40 +++++++------- .../Views/Onboarding/DeviceOnboarding.swift | 2 +- Meshtastic/Views/Settings/AppSettings.swift | 4 ++ .../Views/Settings/SaveChannelQRCode.swift | 6 +++ 20 files changed, 302 insertions(+), 139 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 9bbbd0bd..4e78c69c 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -20930,6 +20930,10 @@ } } }, + "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. You can opt out under app settings." : { + "comment" : "A description of Meshtastic's data collection practices.", + "isCommentAutoGenerated" : true + }, "Meshtastic Node %@ has shared channels with you" : { "localizations" : { "de" : { @@ -40176,6 +40180,9 @@ } } } + }, + "User Privacy" : { + }, "User Uploaded" : { "comment" : "Data source label for user uploaded files", diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+MQTT.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+MQTT.swift index 7c286336..0a246f88 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+MQTT.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+MQTT.swift @@ -32,8 +32,8 @@ extension AccessoryManager { } } // Set initial unread message badge states - appState.unreadChannelMessages = fetchedNodeInfo[0].myInfo?.unreadMessages ?? 0 - appState.unreadDirectMessages = fetchedNodeInfo[0].user?.unreadMessages ?? 0 + appState.unreadChannelMessages = fetchedNodeInfo[0].myInfo?.unreadMessages(context: context) ?? 0 + appState.unreadDirectMessages = fetchedNodeInfo[0].user?.unreadMessages(context: context, skipLastMessageCheck: true) ?? 0 // skipLastMessageCheck=true because we don't update lastMessage on our own connected node } if fetchedNodeInfo.count == 1 && fetchedNodeInfo[0].rangeTestConfig?.enabled == true { wantRangeTestPackets = true diff --git a/Meshtastic/Accessory/Protocols/Device.swift b/Meshtastic/Accessory/Protocols/Device.swift index 03ae1d1d..844eff31 100644 --- a/Meshtastic/Accessory/Protocols/Device.swift +++ b/Meshtastic/Accessory/Protocols/Device.swift @@ -23,7 +23,10 @@ struct Device: Identifiable, Hashable { var connectionState: ConnectionState var wasRestored: Bool = false - init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil, num: Int64? = nil, wasRestored: Bool = false) { + + var connectionDetails: String? + + init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil, num: Int64? = nil, connectionDetails: String? = nil, wasRestored: Bool = false) { self.id = id self.name = name self.transportType = transportType @@ -32,6 +35,7 @@ struct Device: Identifiable, Hashable { self.rssi = rssi self.num = num self.wasRestored = wasRestored + self.connectionDetails = connectionDetails } var rssiString: String { diff --git a/Meshtastic/Accessory/Transports/TCP/TCPTransport.swift b/Meshtastic/Accessory/Transports/TCP/TCPTransport.swift index d753fc76..787d257c 100644 --- a/Meshtastic/Accessory/Transports/TCP/TCPTransport.swift +++ b/Meshtastic/Accessory/Transports/TCP/TCPTransport.swift @@ -78,10 +78,27 @@ class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDe // Save the resolved service locally for later services[service.name] = ResolvedService(id: idString, service: service, host: host, port: port) + let name: String + if let txtRecords = service.txtRecordData().map({NetService.dictionary(fromTXTRecord: $0)}) { + var nodeNameString = "" + if let shortNameData = txtRecords["shortname"] { + nodeNameString += String(decoding: shortNameData, as: UTF8.self) + } + if let nodeId = txtRecords["id"], nodeId.count > 4 { + if nodeNameString.count > 0 { + nodeNameString += "_" + } + nodeNameString += String(decoding: nodeId.suffix(4), as: UTF8.self) + } + name = nodeNameString + } else { + name = "\(service.name) (\(ip))" + } let device = Device(id: idString, - name: "\(service.name) (\(ip))", + name: name, transportType: .tcp, - identifier: "\(host):\(port)") + identifier: "\(host):\(port)", + connectionDetails: "\(ip):\(port)") continuation?.yield(.deviceFound(device)) } diff --git a/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift b/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift index c85eef4a..8d7962b4 100644 --- a/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift @@ -9,22 +9,46 @@ import CoreData import MeshtasticProtobufs extension ChannelEntity { + var messagePredicate: NSPredicate { + return NSPredicate(format: "channel == %ld AND toUser == nil AND isEmoji == false", self.index) + } + + var messageFetchRequest: NSFetchRequest { + let fetchRequest = MessageEntity.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)] + fetchRequest.predicate = messagePredicate + return fetchRequest + } var allPrivateMessages: [MessageEntity] { let context = PersistenceController.shared.container.viewContext - let fetchRequest = MessageEntity.fetchRequest() - fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)] - fetchRequest.predicate = NSPredicate(format: "channel == %ld AND toUser == nil AND isEmoji == false", self.index) + let fetchRequest = messageFetchRequest return (try? context.fetch(fetchRequest)) ?? [MessageEntity]() } - var unreadMessages: Int { + var mostRecentPrivateMessage: MessageEntity? { + // Most recent channel message (descending, limit 1) + let context = PersistenceController.shared.container.viewContext + let fetchRequest = messageFetchRequest + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: false)] + fetchRequest.fetchLimit = 1 - let unreadMessages = allPrivateMessages.filter { ($0 as AnyObject).read == false } - return unreadMessages.count + return (try? context.fetch(fetchRequest))?.first } + func unreadMessages(context: NSManagedObjectContext) -> Int { + let context = PersistenceController.shared.container.viewContext + let fetchRequest = messageFetchRequest + fetchRequest.sortDescriptors = [] // sort is irrelvant. + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")]) + + return (try? context.count(for: fetchRequest)) ?? 0 + } + + // Backwards-compatible property (uses viewContext) + var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.container.viewContext) } + var protoBuf: Channel { var channel = Channel() channel.index = self.index diff --git a/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift b/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift index 68a48ee1..136b64cb 100644 --- a/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift @@ -6,23 +6,39 @@ // import Foundation +import CoreData extension MyInfoEntity { + var messagePredicate: NSPredicate { + return NSPredicate(format: "toUser == nil AND isEmoji == false") + } + + var messageFetchRequest: NSFetchRequest { + let fetchRequest = MessageEntity.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)] + fetchRequest.predicate = messagePredicate + return fetchRequest + } var messageList: [MessageEntity] { let context = PersistenceController.shared.container.viewContext - let fetchRequest = MessageEntity.fetchRequest() - fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)] - fetchRequest.predicate = NSPredicate(format: "toUser == nil") + let fetchRequest = messageFetchRequest - return (try? context.fetch(fetchRequest)) ?? [MessageEntity]() + return (try? context.fetch(messageFetchRequest)) ?? [MessageEntity]() } - var unreadMessages: Int { - let unreadMessages = messageList.filter { ($0 as AnyObject).read == false && ($0 as AnyObject).isEmoji == false } - return unreadMessages.count + func unreadMessages(context: NSManagedObjectContext) -> Int { + // Returns the count of unread *channel* messages + let fetchRequest = messageFetchRequest + fetchRequest.sortDescriptors = [] // sort is irrelvant. + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")]) + + return (try? context.count(for: fetchRequest)) ?? 0 } + // Backwards-compatible property (uses viewContext) + var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.container.viewContext) } + var hasAdmin: Bool { let adminChannel = channels?.filter { ($0 as AnyObject).name?.lowercased() == "admin" } return adminChannel?.count ?? 0 > 0 diff --git a/Meshtastic/Extensions/CoreData/UserEntityExtension.swift b/Meshtastic/Extensions/CoreData/UserEntityExtension.swift index 2a87ed9f..7774ba80 100644 --- a/Meshtastic/Extensions/CoreData/UserEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/UserEntityExtension.swift @@ -10,16 +10,37 @@ import CoreData import MeshtasticProtobufs extension UserEntity { + var messagePredicate: NSPredicate { + return NSPredicate(format: "((toUser == %@) OR (fromUser == %@)) AND toUser != nil AND fromUser != nil AND isEmoji == false AND admin = false AND portNum != 10", self, self) + } + + var messageFetchRequest: NSFetchRequest { + let fetchRequest = MessageEntity.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)] + fetchRequest.predicate = messagePredicate + return fetchRequest + } var messageList: [MessageEntity] { let context = PersistenceController.shared.container.viewContext - let fetchRequest = MessageEntity.fetchRequest() - fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)] - fetchRequest.predicate = NSPredicate(format: "((toUser == %@) OR (fromUser == %@)) AND toUser != nil AND fromUser != nil AND isEmoji == false AND admin = false AND portNum != 10", self, self) + let fetchRequest = messageFetchRequest return (try? context.fetch(fetchRequest)) ?? [MessageEntity]() } + var mostRecentMessage: MessageEntity? { + // Most contacts will have no DMs history, so we can return early. + guard self.lastMessage != nil else { return nil; } + + // Most recent DM for this user (descending, limit 1) + let context = PersistenceController.shared.container.viewContext + let fetchRequest = messageFetchRequest + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: false)] + fetchRequest.fetchLimit = 1 + + return (try? context.fetch(fetchRequest))?.first + } + var sensorMessageList: [MessageEntity] { let context = PersistenceController.shared.container.viewContext let fetchRequest = MessageEntity.fetchRequest() @@ -29,10 +50,21 @@ extension UserEntity { return (try? context.fetch(fetchRequest)) ?? [MessageEntity]() } - var unreadMessages: Int { - let unreadMessages = messageList.filter { ($0 as AnyObject).read == false && ($0 as AnyObject).isEmoji == false } - return unreadMessages.count + func unreadMessages(context: NSManagedObjectContext, skipLastMessageCheck: Bool = false) -> Int { + // Most contacts will have no DMs history, so we can return early. + // (For our own node, set skipLastMessageCheck=true, because we don't update lastMessage on our own connected node.) + guard self.lastMessage != nil || skipLastMessageCheck else { return 0; } + + let fetchRequest = messageFetchRequest + fetchRequest.sortDescriptors = [] // sort is irrelvant. + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")]) + + return (try? context.count(for: fetchRequest)) ?? 0 } + + // Backwards-compatible property (uses viewContext) + var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.container.viewContext) } + /// SVG Images for Vendors who are signed project backers var hardwareImage: String? { guard let hwModel else { return nil } diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 4d217dc7..caffd8b4 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -1040,7 +1040,10 @@ func textMessageAppPacket( if newMessage.fromUser != nil && newMessage.toUser != nil { // Set Unread Message Indicators if packet.to == connectedNode { - appState?.unreadDirectMessages = newMessage.toUser?.unreadMessages ?? 0 + let unreadCount = newMessage.toUser?.unreadMessages(context: context, skipLastMessageCheck: true) ?? 0 // skipLastMessageCheck=true because we don't update lastMessage on our own connected node + Task { @MainActor in + appState?.unreadDirectMessages = unreadCount + } } if !(newMessage.fromUser?.mute ?? false) && newMessage.isEmoji == false { // Create an iOS Notification for the received DM message @@ -1068,7 +1071,7 @@ func textMessageAppPacket( do { let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) if !fetchedMyInfo.isEmpty { - appState?.unreadChannelMessages = fetchedMyInfo[0].unreadMessages + appState?.unreadChannelMessages = fetchedMyInfo[0].unreadMessages(context: context) for channel in (fetchedMyInfo[0].channels?.array ?? []) as? [ChannelEntity] ?? [] { if channel.index == newMessage.channel { context.refresh(channel, mergeChanges: true) diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index a83ddb9b..fccf9a7c 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -19,10 +19,8 @@ struct MeshtasticAppleApp: App { private let persistenceController: PersistenceController private let accessoryManager: AccessoryManager @Environment(\.scenePhase) var scenePhase - @State var saveChannels = false + @State var saveChannelLink: SaveChannelLinkData? @State var incomingUrl: URL? - @State var channelSettings: String? - @State var addChannels = false init() { @@ -36,7 +34,7 @@ struct MeshtasticAppleApp: App { let appID = "79fe92a9-74c9-4c8f-ba63-6308384ecfa9" let clientToken = "pub4427bea20dbdb08a6af68034de22cd3b" var environment = "AppStore" - + #if DEBUG environment = "Local" #else @@ -44,7 +42,8 @@ struct MeshtasticAppleApp: App { environment = "TestFlight" } #endif - + +#if false Datadog.initialize( with: Datadog.Configuration( clientToken: clientToken, @@ -81,6 +80,7 @@ struct MeshtasticAppleApp: App { ) ) } +#endif accessoryManager = AccessoryManager.shared accessoryManager.appState = appState @@ -110,20 +110,11 @@ struct MeshtasticAppleApp: App { appState: appState, router: appState.router ) - .sheet(isPresented: Binding( - get: { - saveChannels && !(channelSettings == nil) - }, - set: { newValue in - saveChannels = newValue - if !newValue { - channelSettings = nil - } - } - )) { + .sheet(item: $saveChannelLink + ) { link in SaveChannelQRCode( - channelSetLink: channelSettings ?? "Empty Channel URL", - addChannels: addChannels, + channelSetLink: link.data, + addChannels: link.add, accessoryManager: accessoryManager ) .presentationDetents([.large]) .presentationDragIndicator(.visible) @@ -131,54 +122,54 @@ struct MeshtasticAppleApp: App { .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in Logger.mesh.debug("URL received \(userActivity, privacy: .public)") self.incomingUrl = userActivity.webpageURL - self.saveChannels = false + self.saveChannelLink = nil + var addChannels = false if self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/v/#") == true { ContactURLHandler.handleContactUrl(url: self.incomingUrl!, accessoryManager: accessoryManager) } else if self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/e/") == true { if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") { - self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false + addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false if (self.incomingUrl?.absoluteString.lowercased().contains("?")) != nil { guard let cs = components.last!.components(separatedBy: "?").first else { return } - self.channelSettings = cs + self.saveChannelLink = SaveChannelLinkData(data: cs, add: addChannels) } else { guard let cs = components.first else { return } - self.channelSettings = cs + self.saveChannelLink = SaveChannelLinkData(data: cs, add: addChannels) } - Logger.services.debug("Add Channel \(self.addChannels, privacy: .public)") + Logger.services.debug("Add Channel \(addChannels, privacy: .public)") } - self.saveChannels = true Logger.mesh.debug("User wants to open a Channel Settings URL: \(self.incomingUrl?.absoluteString ?? "No QR Code Link")") } - if self.saveChannels { + if self.saveChannelLink != nil { Logger.mesh.debug("User wants to open Channel Settings URL: \(String(describing: self.incomingUrl!.relativeString), privacy: .public)") } } .onOpenURL(perform: { (url) in Logger.mesh.debug("Some sort of URL was received \(url, privacy: .public)") self.incomingUrl = url + var addChannels = false if url.absoluteString.lowercased().contains("meshtastic.org/v/#") { ContactURLHandler.handleContactUrl(url: url, accessoryManager: accessoryManager) } else if url.absoluteString.lowercased().contains("meshtastic.org/e/") { if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") { - self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false + addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false if self.incomingUrl?.absoluteString.lowercased().contains("?") != nil { guard let cs = components.last!.components(separatedBy: "?").first else { return } - self.channelSettings = cs + self.saveChannelLink = SaveChannelLinkData(data: cs, add: addChannels) } else { guard let cs = components.first else { return } - self.channelSettings = cs + self.saveChannelLink = SaveChannelLinkData(data: cs, add: addChannels) } - Logger.services.debug("Add Channel \(self.addChannels, privacy: .public)") + Logger.services.debug("Add Channel \(addChannels, privacy: .public)") } - self.saveChannels = true Logger.mesh.debug("User wants to open a Channel Settings URL: \(self.incomingUrl?.absoluteString ?? "No QR Code Link", privacy: .public)") } else if url.absoluteString.lowercased().contains("meshtastic:///") { appState.router.route(url: url) @@ -221,7 +212,7 @@ struct MeshtasticAppleApp: App { .environment(\.managedObjectContext, persistenceController.container.viewContext) .environmentObject(appState) .environmentObject(accessoryManager) - .environmentObject(appState.router) + .environmentObject(appState.router) } } diff --git a/Meshtastic/Views/Connect/Connect.swift b/Meshtastic/Views/Connect/Connect.swift index 8915d9ff..7a264aa6 100644 --- a/Meshtastic/Views/Connect/Connect.swift +++ b/Meshtastic/Views/Connect/Connect.swift @@ -64,10 +64,22 @@ struct Connect: View { } Text("Connection Name").font(.callout)+Text(": \(connectedDevice.name.addingVariationSelectors)") .font(.callout).foregroundColor(Color.gray) - HStack(alignment: .firstTextBaseline) { - TransportIcon(transportType: connectedDevice.transportType) + HStack { if connectedDevice.transportType == .ble { - connectedDevice.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0, width: 5, height: 20) } + // baseline aligned looks better for the signal meter + HStack(alignment: .firstTextBaseline) { + TransportIcon(transportType: connectedDevice.transportType) + connectedDevice.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0, width: 5, height: 20) } + } + } else if connectedDevice.transportType == .tcp { + // Not baseline aligned looks better for the connection string + HStack { + TransportIcon(transportType: connectedDevice.transportType) + Text("\(connectedDevice.connectionDetails ?? connectedDevice.identifier)") + .foregroundColor(.gray) + } + } else { + TransportIcon(transportType: connectedDevice.transportType) } Spacer() } @@ -297,7 +309,14 @@ struct Connect: View { Text(device.name).font(.callout) } // Show transport type - TransportIcon(transportType: device.transportType) + HStack { + TransportIcon(transportType: device.transportType) + if device.transportType == .tcp { + // Show IP and Port + Text("\(device.connectionDetails ?? device.identifier)") + .foregroundColor(.gray) + } + } } Spacer() VStack { diff --git a/Meshtastic/Views/Messages/ChannelList.swift b/Meshtastic/Views/Messages/ChannelList.swift index 5b3af8f6..16660203 100644 --- a/Meshtastic/Views/Messages/ChannelList.swift +++ b/Meshtastic/Views/Messages/ChannelList.swift @@ -37,14 +37,16 @@ struct ChannelList: View { let dateFormatString = (localeDateFormat ?? "MM/dd/YY") NavigationLink(value: channel) { - let mostRecent = channel.allPrivateMessages.last(where: { $0.channel == channel.index }) + let mostRecent = channel.mostRecentPrivateMessage + let hasMessages = mostRecent != nil + let hasUnreadMessages = hasMessages && (channel.unreadMessages > 0) let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 )))) let lastMessageDay = Calendar.current.dateComponents([.day], from: lastMessageTime).day ?? 0 let currentDay = Calendar.current.dateComponents([.day], from: Date()).day ?? 0 ZStack { Image(systemName: "circle.fill") - .opacity(channel.unreadMessages > 0 ? 1 : 0) + .opacity(hasUnreadMessages ? 1 : 0) .font(.system(size: 10)) .foregroundColor(.accentColor) .brightness(0.2) @@ -70,7 +72,7 @@ struct ChannelList: View { Spacer() - if channel.allPrivateMessages.count > 0 { + if hasMessages { if lastMessageDay == currentDay { Text(lastMessageTime, style: .time ) @@ -95,7 +97,7 @@ struct ChannelList: View { } } - if channel.allPrivateMessages.count > 0 { + if hasMessages { HStack(alignment: .top) { Text("\(mostRecent != nil ? mostRecent!.messagePayload! : " ")") // .font(.system(size: 16)) @@ -112,6 +114,7 @@ struct ChannelList: View { if let node, let myInfo = node.myInfo { List(selection: $channelSelection) { ForEach(channels) { (channel: ChannelEntity) in + let hasMessages = channel.mostRecentPrivateMessage != nil if !restrictedChannels.contains(channel.name?.lowercased() ?? "") { makeChannelRow(myInfo: myInfo, channel: channel) .alignmentGuide(.listRowSeparatorLeading) { @@ -119,7 +122,7 @@ struct ChannelList: View { } .frame(height: 62) .contextMenu { - if channel.allPrivateMessages.count > 0 { + if hasMessages { Button(role: .destructive) { isPresentingDeleteChannelMessagesConfirm = true channelToDeleteMessages = channel diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index 857028f1..ba0bc701 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -13,6 +13,7 @@ import SwiftUI struct ChannelMessageList: View { @EnvironmentObject var appState: AppState @EnvironmentObject var router: Router + @Environment(\.scenePhase) var scenePhase @Environment(\.managedObjectContext) var context @EnvironmentObject var accessoryManager: AccessoryManager @FocusState var messageFieldFocused: Bool @@ -58,14 +59,29 @@ struct ChannelMessageList: View { Logger.data.error("Failed to read messages: \(error.localizedDescription, privacy: .public)") } } - + + private func routerIsShowingThisChannel() -> Bool { + guard router.navigationState.selectedTab == .messages else { return false } + return scenePhase == .active + } + var body: some View { + // Cast allPrivateMessages to an array for easier indexing and ForEach. + let messages: [MessageEntity] = Array(allPrivateMessages) + + // Precompute previous message + let previousByID: [Int64: MessageEntity?] = { + var dict = [Int64: MessageEntity?]() + var prev: MessageEntity? + for m in messages { dict[m.messageId] = prev; prev = m } + return dict + }() + ScrollViewReader { scrollView in ScrollView { LazyVStack { - ForEach(allPrivateMessages.indices, id: \.self) { index in - let message = allPrivateMessages[index] - let previousMessage = index > 0 ? allPrivateMessages[index - 1] : nil + ForEach(messages, id: \.messageId) { message in + let previousMessage: MessageEntity? = previousByID[message.messageId] ?? nil ChannelMessageRow( message: message, @@ -92,7 +108,7 @@ struct ChannelMessageList: View { } } } - .id(redrawTapbacksTrigger) + } Color.clear .frame(height: 1) diff --git a/Meshtastic/Views/Messages/MessageText.swift b/Meshtastic/Views/Messages/MessageText.swift index 19d0c715..28df8fba 100644 --- a/Meshtastic/Views/Messages/MessageText.swift +++ b/Meshtastic/Views/Messages/MessageText.swift @@ -25,9 +25,7 @@ struct MessageText: View { let isCurrentUser: Bool let onReply: () -> Void // State for handling channel URL sheet - @State private var saveChannels = false - @State private var channelSettings: String? - @State private var addChannels = false + @State private var saveChannelLink: SaveChannelLinkData? @State private var isShowingDeleteConfirmation = false var body: some View { @@ -97,7 +95,8 @@ struct MessageText: View { ) } .environment(\.openURL, OpenURLAction { url in - channelSettings = nil + saveChannelLink = nil + var addChannels = false if url.absoluteString.lowercased().contains("meshtastic.org/v/#") { // Handle contact URL ContactURLHandler.handleContactUrl(url: url, accessoryManager: AccessoryManager.shared) @@ -109,35 +108,25 @@ struct MessageText: View { Logger.services.error("No valid components found in channel URL: \(url.absoluteString, privacy: .public)") return .discarded } - self.addChannels = Bool(url.query?.contains("add=true") ?? false) + addChannels = Bool(url.query?.contains("add=true") ?? false) guard let lastComponent = components.last else { Logger.services.error("Channel URL missing fragment component: \(url.absoluteString, privacy: .public)") - self.channelSettings = nil + self.saveChannelLink = nil return .discarded } - self.channelSettings = lastComponent.components(separatedBy: "?").first ?? "" - Logger.services.debug("Add Channel: \(self.addChannels, privacy: .public)") - self.saveChannels = true + let cs = lastComponent.components(separatedBy: "?").first ?? "" + self.saveChannelLink = SaveChannelLinkData(data: cs, add: addChannels) + Logger.services.debug("Add Channel: \(addChannels, privacy: .public)") Logger.mesh.debug("Opening Channel Settings URL: \(url.absoluteString, privacy: .public)") return .handled // Prevent default browser opening } return .systemAction // Open other URLs in browser }) // Display sheet for channel settings - .sheet(isPresented: Binding( - get: { - saveChannels && !(channelSettings == nil) - }, - set: { newValue in - saveChannels = newValue - if !newValue { - channelSettings = nil - } - } - )) { + .sheet(item: $saveChannelLink) { link in SaveChannelQRCode( - channelSetLink: channelSettings ?? "Empty Channel URL", - addChannels: addChannels, + channelSetLink: link.data, + addChannels: link.add, accessoryManager: accessoryManager ) .presentationDetents([.large]) diff --git a/Meshtastic/Views/Messages/UserList.swift b/Meshtastic/Views/Messages/UserList.swift index 2199e997..16ba0b7b 100644 --- a/Meshtastic/Views/Messages/UserList.swift +++ b/Meshtastic/Views/Messages/UserList.swift @@ -11,7 +11,7 @@ import OSLog import TipKit struct UserList: View { - + @Environment(\.managedObjectContext) var context @EnvironmentObject var accessoryManager: AccessoryManager @State private var editingFilters = false @@ -20,7 +20,7 @@ struct UserList: View { @StateObject private var filters: NodeFilterParameters = NodeFilterParameters() @Binding var node: NodeInfoEntity? @Binding var userSelection: UserEntity? - + var body: some View { VStack { FilteredUserList(withFilters: filters, node: $node, userSelection: $userSelection) @@ -92,13 +92,15 @@ fileprivate struct FilteredUserList: View { self._node = node self._userSelection = userSelection } - + var body: some View { let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMdd", options: 0, locale: Locale.current) let dateFormatString = (localeDateFormat ?? "MM/dd/YY") List(users, selection: $userSelection) { user in - let mostRecent = user.messageList.last + let mostRecent = user.mostRecentMessage + let hasMessages = mostRecent != nil + let hasUnreadMessages = user.unreadMessages > 0 let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 )))) let lastMessageDay = Calendar.current.dateComponents([.day], from: lastMessageTime).day ?? 0 let currentDay = Calendar.current.dateComponents([.day], from: Date()).day ?? 0 @@ -106,14 +108,14 @@ fileprivate struct FilteredUserList: View { NavigationLink(value: user) { ZStack { Image(systemName: "circle.fill") - .opacity(user.unreadMessages > 0 ? 1 : 0) + .opacity(hasUnreadMessages ? 1 : 0) .font(.system(size: 10)) .foregroundColor(.accentColor) .brightness(0.2) } - + CircleText(text: user.shortName ?? "?", color: Color(UIColor(hex: UInt32(user.num)))) - + VStack(alignment: .leading) { HStack { if user.pkiEncrypted { @@ -137,7 +139,7 @@ fileprivate struct FilteredUserList: View { Image(systemName: "star.fill") .foregroundColor(.yellow) } - if user.messageList.count > 0 { + if hasMessages { if lastMessageDay == currentDay { Text(lastMessageTime, style: .time ) .font(.footnote) @@ -157,8 +159,8 @@ fileprivate struct FilteredUserList: View { } } } - - if user.messageList.count > 0 { + + if hasMessages { HStack(alignment: .top) { Text("\(mostRecent != nil ? mostRecent!.messagePayload! : " ")") .font(.footnote) @@ -207,7 +209,7 @@ fileprivate struct FilteredUserList: View { } label: { Label(user.mute ? "Show Alerts" : "Hide Alerts", systemImage: user.mute ? "bell" : "bell.slash") } - if user.messageList.count > 0 { + if hasMessages { Button(role: .destructive) { isPresentingDeleteUserMessagesConfirm = true userToDeleteMessages = user @@ -316,7 +318,7 @@ fileprivate extension NodeFilterParameters { predicates.append(isIgnoredPredicate) let isConnectedNodePredicate = NSPredicate(format: "NOT (numString CONTAINS %@)", String(UserDefaults.preferredPeripheralNum)) predicates.append(isConnectedNodePredicate) - + // Combine all predicates let finalPredicate = predicates.isEmpty ? NSPredicate(value: true) : NSCompoundPredicate(type: .and, subpredicates: predicates) return finalPredicate diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index 87afb16f..394431d5 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -11,9 +11,10 @@ import OSLog import MeshtasticProtobufs // Added to ensure RoutingError is accessible if needed struct UserMessageList: View { - @EnvironmentObject var appState: AppState + @EnvironmentObject var router: Router @EnvironmentObject var accessoryManager: AccessoryManager + @Environment(\.scenePhase) var scenePhase @Environment(\.managedObjectContext) var context @FocusState var messageFieldFocused: Bool @ObservedObject var user: UserEntity @@ -21,17 +22,21 @@ struct UserMessageList: View { @State private var messageToHighlight: Int64 = 0 @State private var redrawTapbacksTrigger = UUID() @AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1 - - private var allPrivateMessages: [MessageEntity] { - // Cast user.messageList to an array for easier indexing and ForEach. - return user.messageList.compactMap { $0 as MessageEntity } + @FetchRequest private var allPrivateMessages: FetchedResults + + init(user: UserEntity) { + self.user = user + + // Configure fetch request here + let request: NSFetchRequest = user.messageFetchRequest + _allPrivateMessages = FetchRequest(fetchRequest: request) } - + func handleInteractionComplete() { markMessagesAsRead() redrawTapbacksTrigger = UUID() } - + func markMessagesAsRead() { do { for unreadMessage in allPrivateMessages.filter({ !$0.read }) { @@ -39,25 +44,46 @@ struct UserMessageList: View { } try context.save() Logger.data.info("📖 [App] All unread direct messages marked as read for user \(user.num, privacy: .public).") - appState.unreadDirectMessages = user.unreadMessages + + if let connectedPeripheralNum = accessoryManager.activeDeviceNum, + let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: context), + let connectedUser = connectedNode.user { + appState.unreadDirectMessages = connectedUser.unreadMessages(context: context, skipLastMessageCheck: true) // skipLastMessageCheck=true because we don't update lastMessage on our own connected node + } + context.refresh(user, mergeChanges: true) } catch { Logger.data.error("Failed to read direct messages: \(error.localizedDescription, privacy: .public)") } } - + + private func routerIsShowingThisUser() -> Bool { + guard router.navigationState.selectedTab == .messages else { return false } + return scenePhase == .active + } + var body: some View { + // Cast user.messageList to an array for easier indexing and ForEach. + let messages: [MessageEntity] = Array(allPrivateMessages) + + // Precompute previous message + let previousByID: [Int64: MessageEntity?] = { + var dict = [Int64: MessageEntity?]() + var prev: MessageEntity? + for m in messages { dict[m.messageId] = prev; prev = m } + return dict + }() + VStack { ScrollViewReader { scrollView in ScrollView { LazyVStack { - ForEach(allPrivateMessages.indices, id: \.self) { index in - let message = allPrivateMessages[index] - let previousMessage = index > 0 ? allPrivateMessages[index - 1] : nil + ForEach(messages, id: \.messageId) { message in + let previousMessage: MessageEntity? = previousByID[message.messageId] ?? nil UserMessageRow( message: message, - allMessages: allPrivateMessages, + allMessages: messages, previousMessage: previousMessage, preferredPeripheralNum: preferredPeripheralNum, user: user, @@ -80,7 +106,7 @@ struct UserMessageList: View { } } } - .id(redrawTapbacksTrigger) + } // Invisible spacer to detect reaching bottom Color.clear diff --git a/Meshtastic/Views/Nodes/Helpers/ShareContactQRDialog.swift b/Meshtastic/Views/Nodes/Helpers/ShareContactQRDialog.swift index 4bc56e24..cf30b441 100644 --- a/Meshtastic/Views/Nodes/Helpers/ShareContactQRDialog.swift +++ b/Meshtastic/Views/Nodes/Helpers/ShareContactQRDialog.swift @@ -13,12 +13,14 @@ import MeshtasticProtobufs import OSLog struct ShareContactQRDialog: View { + let manuallyVerified = false let node: NodeInfo @Environment(\.dismiss) private var dismiss var qrString: String { var contact = SharedContact() contact.nodeNum = node.num contact.user = node.user + contact.manuallyVerified = manuallyVerified do { let contactString = try contact.serializedData().base64EncodedString() return ("https://meshtastic.org/v/#" + contactString.base64ToBase64url()) diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 34393253..f324ed8b 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -24,14 +24,14 @@ struct NodeList: View { @StateObject var filters = NodeFilterParameters() @State var isEditingFilters = false @SceneStorage("selectedDetailView") var selectedDetailView: String? - + var connectedNode: NodeInfoEntity? { if let num = accessoryManager.activeDeviceNum { return getNodeInfo(id: num, context: context) } return nil } - + var body: some View { NavigationSplitView { FilteredNodeList( @@ -137,7 +137,7 @@ struct NodeList: View { } } } - + // Helper to get the count of nodes for the navigation title private func getNodeCount() -> Int { let request: NSFetchRequest = NodeInfoEntity.fetchRequest() @@ -154,7 +154,7 @@ fileprivate struct FilteredNodeList: View { @EnvironmentObject var accessoryManager: AccessoryManager @FetchRequest private var nodes: FetchedResults @Environment(\.managedObjectContext) var context - + @Binding var selectedNode: NodeInfoEntity? var connectedNode: NodeInfoEntity? @Binding var isPresentingDeleteNodeAlert: Bool @@ -179,17 +179,19 @@ fileprivate struct FilteredNodeList: View { ] request.predicate = withFilters.buildPredicate() self._nodes = FetchRequest(fetchRequest: request) - + self._selectedNode = selectedNode self.connectedNode = connectedNode self._isPresentingDeleteNodeAlert = isPresentingDeleteNodeAlert self._deleteNodeId = deleteNodeId self._shareContactNode = shareContactNode } - + // The body of the view var body: some View { - List(nodes, id: \.self, selection: $selectedNode) { node in + // If the connected node passes filters, always show it first + let nodesWithConnectedFirst = nodes.filter { $0.num == accessoryManager.activeDeviceNum } + nodes.filter { $0.num != accessoryManager.activeDeviceNum } + List(nodesWithConnectedFirst, id: \.self, selection: $selectedNode) { node in NavigationLink(value: node) { NodeListItem( node: node, @@ -205,7 +207,7 @@ fileprivate struct FilteredNodeList: View { } } } - + @ViewBuilder func contextMenuActions( node: NodeInfoEntity, @@ -276,7 +278,7 @@ fileprivate struct FilteredNodeList: View { fileprivate extension NodeFilterParameters { func buildPredicate() -> NSPredicate? { var predicates: [NSPredicate] = [] - + // Search text predicates if !searchText.isEmpty { let searchKeys = [ @@ -288,19 +290,19 @@ fileprivate extension NodeFilterParameters { } predicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: textPredicates)) } - + // Favorite filter if isFavorite { predicates.append(NSPredicate(format: "favorite == YES")) } - + // Via Lora/MQTT filters if viaLora && !viaMqtt { predicates.append(NSPredicate(format: "viaMqtt == NO")) } else if !viaLora && viaMqtt { predicates.append(NSPredicate(format: "viaMqtt == YES")) } - + // Role filter if roleFilter && !deviceRoles.isEmpty { let rolesPredicates = deviceRoles.map { @@ -308,41 +310,41 @@ fileprivate extension NodeFilterParameters { } predicates.append(NSCompoundPredicate(type: .or, subpredicates: rolesPredicates)) } - + // Hops Away filter if hopsAway == 0.0 { predicates.append(NSPredicate(format: "hopsAway == %i", 0)) } else if hopsAway > 0.0 { predicates.append(NSPredicate(format: "hopsAway > 0 AND hopsAway <= %i", Int32(hopsAway))) } - + // Online filter if isOnline { let isOnlinePredicate = NSPredicate(format: "lastHeard >= %@", Calendar.current.date(byAdding: .minute, value: -120, to: Date())! as NSDate) predicates.append(isOnlinePredicate) } - + // Encrypted filter if isPkiEncrypted { predicates.append(NSPredicate(format: "user.pkiEncrypted == YES")) } - + // Ignored filter if isIgnored { predicates.append(NSPredicate(format: "ignored == YES")) } else { predicates.append(NSPredicate(format: "ignored == NO")) } - + // Environment filter if isEnvironment { predicates.append(NSPredicate(format: "SUBQUERY(telemetries, $tel, $tel.metricsType == 1).@count > 0")) } - + // Distance filter if distanceFilter { if let pointOfInterest = LocationsHandler.currentLocation { - + if pointOfInterest.latitude != LocationsHandler.DefaultLocation.latitude && pointOfInterest.longitude != LocationsHandler.DefaultLocation.longitude { let d: Double = maxDistance * 1.1 let r: Double = 6371009 diff --git a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift index 6f7c87a3..d95d6977 100644 --- a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift +++ b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift @@ -59,7 +59,7 @@ struct DeviceOnboarding: View { makeRow( icon: "person.2.shield", title: String(localized: "User Privacy"), - subtitle: String(localized: "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. This helps us understand how the app is being used and where we can make improvements. The data we collect is non-personally identifiable and cannot be linked to you as an individual. You can opt out of this under app settings.") + subtitle: String(localized: "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. You can opt out under app settings.") ) } .padding() diff --git a/Meshtastic/Views/Settings/AppSettings.swift b/Meshtastic/Views/Settings/AppSettings.swift index 4e1ab892..6a809b42 100644 --- a/Meshtastic/Views/Settings/AppSettings.swift +++ b/Meshtastic/Views/Settings/AppSettings.swift @@ -56,6 +56,9 @@ struct AppSettings: View { } .tint(.accentColor) } +#if targetEnvironment(macCatalyst) + +#else Button { isPresentingAppIconSheet.toggle() } label: { @@ -65,6 +68,7 @@ struct AppSettings: View { AppIconPicker(isPresenting: self.$isPresentingAppIconSheet) .presentationDetents([.medium]) } +#endif } Section(header: Text("environment")) { VStack(alignment: .leading) { diff --git a/Meshtastic/Views/Settings/SaveChannelQRCode.swift b/Meshtastic/Views/Settings/SaveChannelQRCode.swift index 71a032b4..cc4c6f84 100644 --- a/Meshtastic/Views/Settings/SaveChannelQRCode.swift +++ b/Meshtastic/Views/Settings/SaveChannelQRCode.swift @@ -9,6 +9,12 @@ import CoreData import OSLog import MeshtasticProtobufs +struct SaveChannelLinkData: Identifiable { + let id = UUID() + let data: String + let add: Bool +} + struct SaveChannelQRCode: View { @Environment(\.dismiss) private var dismiss @Environment(\.managedObjectContext) var context From 6705a184b4878bd7e7c0b6dc289a42382fb85828 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sat, 18 Oct 2025 11:08:31 -0700 Subject: [PATCH 03/13] Explicitly set unmessagable, seems unnessary --- Meshtastic/Extensions/CoreData/UserEntityExtension.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Meshtastic/Extensions/CoreData/UserEntityExtension.swift b/Meshtastic/Extensions/CoreData/UserEntityExtension.swift index 2a87ed9f..bba4075f 100644 --- a/Meshtastic/Extensions/CoreData/UserEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/UserEntityExtension.swift @@ -130,6 +130,7 @@ public func createUser(num: Int64, context: NSManagedObjectContext) throws -> Us newUser.longName = "Meshtastic \(last4)" newUser.shortName = last4 newUser.hwModel = "UNSET" + newUser.unmessagable = false } return newUser From 9e0a1ffea93188777d2a7e8c840b7c9343accdf7 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Mon, 20 Oct 2025 11:33:58 -0700 Subject: [PATCH 04/13] Add back missing mesh map features --- Localizable.xcstrings | 8 --- .../Map/MapContent/MeshMapContent.swift | 49 ++++++++++++------- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 4e78c69c..b7835609 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -3905,10 +3905,6 @@ } } }, - "Anonymous Usage and Crash data" : { - "comment" : "A description of how the app collects and uses data about its usage and crashes. It emphasizes that this data is anonymous and non-personally identifiable.", - "isCommentAutoGenerated" : true - }, "Any missed messages will be delivered again." : { "localizations" : { "it" : { @@ -41047,10 +41043,6 @@ }, "Waypoints" : { - }, - "We anonymously collect usage and crash data to improve the app. This helps us understand how the app is being used and where we can make improvements. The data we collect is non-personally identifiable and cannot be linked to you as an individual. You can opt out of this under app settings." : { - "comment" : "A description of how the app collects and uses user data. Includes a link to the app settings.", - "isCommentAutoGenerated" : true }, "Weather Conditions" : { "localizations" : { diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift index 4941dd30..9ccf3f76 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift @@ -47,26 +47,39 @@ struct MeshMapContent: MapContent { @MapContentBuilder var positionAnnotations: some MapContent { ForEach(positions, id: \.id) { position in + /// Apply favorits filter and don't show ignored nodes if (!showFavorites || (position.nodePosition?.favorite == true)) && !(position.nodePosition?.ignored == true) { - - let nodeColor = UIColor(hex: UInt32(position.nodePosition?.num ?? 0)) - let positionName = position.nodePosition?.user?.longName ?? "?" - // Use a hash of the position ID to stagger animation delays for each node, preventing synchronized animations and improving visual distinction. - let calculatedDelay = Double(position.id.hashValue % 100) / 100.0 * 0.5 - - Annotation(positionName, coordinate: position.coordinate) { - LazyVStack { - AnimatedNodePin( - nodeColor: nodeColor, - shortName: position.nodePosition?.user?.shortName, - hasDetectionSensorMetrics: position.nodePosition?.hasDetectionSensorMetrics ?? false, - isOnline: position.nodePosition?.isOnline ?? false, - calculatedDelay: calculatedDelay - ) + if 12...15 ~= position.precisionBits || position.precisionBits == 32 { + + let nodeColor = UIColor(hex: UInt32(position.nodePosition?.num ?? 0)) + let positionName = position.nodePosition?.user?.longName ?? "?" + /// Reduced Precision Map Circle + if 12...15 ~= position.precisionBits { + let pp = PositionPrecision(rawValue: Int(position.precisionBits)) + let radius: CLLocationDistance = pp?.precisionMeters ?? 0 + if radius > 0.0 { + MapCircle(center: position.coordinate, radius: radius) + .foregroundStyle(Color(nodeColor).opacity(0.25)) + .stroke(.white, lineWidth: 1) + } + } + // Use a hash of the position ID to stagger animation delays for each node, preventing synchronized animations and improving visual distinction. + let calculatedDelay = Double(position.id.hashValue % 100) / 100.0 * 0.5 + + Annotation(positionName, coordinate: position.coordinate) { + LazyVStack { + AnimatedNodePin( + nodeColor: nodeColor, + shortName: position.nodePosition?.user?.shortName, + hasDetectionSensorMetrics: position.nodePosition?.hasDetectionSensorMetrics ?? false, + isOnline: position.nodePosition?.isOnline ?? false, + calculatedDelay: calculatedDelay + ) + } + .highPriorityGesture(TapGesture().onEnded { _ in + selectedPosition = (selectedPosition == position ? nil : position) + }) } - .highPriorityGesture(TapGesture().onEnded { _ in - selectedPosition = (selectedPosition == position ? nil : position) - }) } } } From 16e56e7f07044e2deb2465639392cfbe27dbba3d Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Mon, 20 Oct 2025 11:38:18 -0700 Subject: [PATCH 05/13] Fix: "Retrieving nodes" significantly slower after reconnect extracted from #1424 (#1477) * Fix: "Retrieving nodes" significantly slower after reconnect (#1424) The node database retrieval was calling context.save() for every single NodeInfo packet received (250 saves for 250 nodes). This caused severe performance degradation on reconnect when CoreData had accumulated state. Root Cause: - nodeInfoPacket() called context.save() immediately for each node - With 250 nodes, this meant 250 individual CoreData save operations - On first connection, CoreData is fresh and fast - On reconnect, CoreData has accumulated change tracking, undo management, and memory pressure, making each save progressively slower - This resulted in 10+ second retrieval times vs 1-2 seconds initially Solution: - Added deferSave parameter to nodeInfoPacket() function - During database retrieval (.retrievingDatabase state), defer all saves - Perform a single batch save when database retrieval completes (when NONCE_ONLY_DB configCompleteID is received) - This reduces 250 saves to 1 save Performance Impact: - Eliminates N individual saves during node database sync - Reduces database retrieval time back to 1-2 seconds on reconnect - Matches first-connection performance consistently Fixes #1424 * Revert *MessageListUnified files --------- Co-authored-by: Martin Bogomolni Co-authored-by: Jake-B --- .../AccessoryManager+FromRadio.swift | 5 ++++- .../Accessory Manager/AccessoryManager.swift | 11 +++++++++++ Meshtastic/Helpers/MeshPackets.swift | 14 +++++++++----- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift index 01cdac79..539f4a5e 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift @@ -106,8 +106,11 @@ extension AccessoryManager { return } + // Check if we're in database retrieval mode to defer saves for performance + let isRetrievingDatabase = if case .retrievingDatabase = self.state { true } else { false } + // TODO: nodeInfoPacket's channel: parameter is not used - if let nodeInfo = nodeInfoPacket(nodeInfo: nodeInfo, channel: 0, context: context) { + if let nodeInfo = nodeInfoPacket(nodeInfo: nodeInfo, channel: 0, context: context, deferSave: isRetrievingDatabase) { if let activeDevice = activeConnection?.device, activeDevice.num == nodeInfo.num { if let user = nodeInfo.user { updateDevice(deviceId: activeDevice.id, key: \.shortName, value: user.shortName ?? "?") diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift index 3c507a03..88b70032 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift @@ -665,6 +665,17 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate { self.firstDatabaseNodeInfoContinuation = nil } + // Perform a single batch save after database retrieval completes + // This significantly improves performance on reconnect + do { + try context.save() + Logger.data.info("💾 [Database] Batch saved all node info after database retrieval") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [Database] Error saving batch node info: \(nsError, privacy: .public)") + } + default: Logger.transport.error("[Accessory] Unknown nonce completed: \(configCompleteID)") } diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index caffd8b4..e3aa3252 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -264,7 +264,7 @@ func deviceMetadataPacket (metadata: DeviceMetadata, fromNum: Int64, sessionPass } } -func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObjectContext) -> NodeInfoEntity? { +func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObjectContext, deferSave: Bool = false) -> NodeInfoEntity? { let logString = String.localizedStringWithFormat("[NodeInfo] received for: %@".localized, String(nodeInfo.num)) Logger.mesh.info("📟 \(logString, privacy: .public)") @@ -375,8 +375,10 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje newNode.myInfo = fetchedMyInfo[0] } do { - try context.save() - Logger.data.info("💾 Saved a new Node Info For: \(String(nodeInfo.num), privacy: .public)") + if !deferSave { + try context.save() + Logger.data.info("💾 Saved a new Node Info For: \(String(nodeInfo.num), privacy: .public)") + } return newNode } catch { context.rollback() @@ -500,8 +502,10 @@ func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObje fetchedNode[0].myInfo = fetchedMyInfo[0] } do { - try context.save() - Logger.data.info("💾 [NodeInfo] saved for \(nodeInfo.num.toHex(), privacy: .public)") + if !deferSave { + try context.save() + Logger.data.info("💾 [NodeInfo] saved for \(nodeInfo.num.toHex(), privacy: .public)") + } return fetchedNode[0] } catch { context.rollback() From 4114722ebf1add0199aa3f8407c7728685297fa2 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Tue, 21 Oct 2025 06:03:01 -0700 Subject: [PATCH 06/13] Hide route lines filter from mesh map --- .../Views/Nodes/Helpers/Map/MapSettingsForm.swift | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift index 304c5e69..ca17c6fa 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapSettingsForm.swift @@ -84,15 +84,12 @@ struct MapSettingsForm: View { Label("Node History", systemImage: "building.columns.fill") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) - .onTapGesture { - self.nodeHistory.toggle() - UserDefaults.enableMapNodeHistoryPins = self.nodeHistory + Toggle(isOn: $enableMapRouteLines) { + Label("Route Lines", systemImage: "road.lanes") } + .tint(.accentColor) + } - Toggle(isOn: $enableMapRouteLines) { - Label("Route Lines", systemImage: "road.lanes") - } - .tint(.accentColor) Toggle(isOn: $convexHull) { Label("Convex Hull", systemImage: "button.angledbottom.horizontal.right") } From 6c3c02237b8b33ef9dded5ee0dc393115eeaf4c0 Mon Sep 17 00:00:00 2001 From: Mike Robbins Date: Tue, 21 Oct 2025 09:23:22 -0400 Subject: [PATCH 07/13] Mesh Map: fuzz imprecise locations so they're distinguishable and clickable at the highest zoom levels (#1478) Co-authored-by: Garth Vander Houwen --- .../CoreData/PositionEntityExtension.swift | 30 +++++++++++++++++++ .../Map/MapContent/MeshMapContent.swift | 23 ++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/Meshtastic/Extensions/CoreData/PositionEntityExtension.swift b/Meshtastic/Extensions/CoreData/PositionEntityExtension.swift index 3efa9af3..080693c2 100644 --- a/Meshtastic/Extensions/CoreData/PositionEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/PositionEntityExtension.swift @@ -96,10 +96,40 @@ extension PositionEntity { } return pointAnn } + + var isPreciseLocation: Bool { + precisionBits == 32 || precisionBits == 0 + } + + var fuzzedNodeCoordinate: CLLocationCoordinate2D? { + // With reduced precisionBits, many nodes can overlap on the map, making them unclickable. + // Use a hash of the position ID to fuzz coordinate slightly so that these nodes can be distinguished at the higest zoom levels. This allows them to be clicked individually. + if latitudeI != 0 && longitudeI != 0 { + // Derive two uniform pseudorandom numbers [0,1) from id.hashValue + let u1 = Double(id.hashValue & 0xFFFF) / 65536.0 + let u2 = Double((id.hashValue >> 16) & 0xFFFF) / 65536.0 + + // Angle and radius + let offsetAngle = 2.0 * .pi * u1 + let offsetRadius = 0.00001 * sqrt(u2) // 1.0e-5 degrees at equator is about 1.11 m or 4 ft + + let dLat = sin(offsetAngle) * offsetRadius + let dLon = cos(offsetAngle) * offsetRadius + + let coord = CLLocationCoordinate2D( + latitude: latitude! + dLat, + longitude: longitude! + dLon + ) + return coord + } else { + return nil + } + } } extension PositionEntity: MKAnnotation { public var coordinate: CLLocationCoordinate2D { nodeCoordinate ?? LocationsHandler.DefaultLocation } + public var fuzzedCoordinate: CLLocationCoordinate2D { fuzzedNodeCoordinate ?? LocationsHandler.DefaultLocation } public var title: String? { nodePosition?.user?.shortName ?? "Unknown".localized } public var subtitle: String? { time?.formatted() } } diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift index 9ccf3f76..4edb6ac4 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift @@ -49,6 +49,29 @@ struct MeshMapContent: MapContent { ForEach(positions, id: \.id) { position in /// Apply favorits filter and don't show ignored nodes if (!showFavorites || (position.nodePosition?.favorite == true)) && !(position.nodePosition?.ignored == true) { + + let nodeColor = UIColor(hex: UInt32(position.nodePosition?.num ?? 0)) + let positionName = position.nodePosition?.user?.longName ?? "?" + // Use a hash of the position ID to stagger animation delays for each node, preventing synchronized animations and improving visual distinction. + let calculatedDelay = Double(position.id.hashValue % 100) / 100.0 * 0.5 + + let coordinateForNodePin: CLLocationCoordinate2D = if position.isPreciseLocation { + // Precise location: place node pin at actual location. + position.coordinate + } else { + // Imprecise location: fuzz slightly so overlapping nodes are visible and clickable at highest zoom levels. + position.fuzzedCoordinate + } + + Annotation(positionName, coordinate: coordinateForNodePin) { + LazyVStack { + AnimatedNodePin( + nodeColor: nodeColor, + shortName: position.nodePosition?.user?.shortName, + hasDetectionSensorMetrics: position.nodePosition?.hasDetectionSensorMetrics ?? false, + isOnline: position.nodePosition?.isOnline ?? false, + calculatedDelay: calculatedDelay + ) if 12...15 ~= position.precisionBits || position.precisionBits == 32 { let nodeColor = UIColor(hex: UInt32(position.nodePosition?.num ?? 0)) From 4aa56b15311a43389ff79d8565f97667e2f044aa Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Tue, 21 Oct 2025 06:28:33 -0700 Subject: [PATCH 08/13] Fix bad merge --- .../Map/MapContent/MeshMapContent.swift | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift index 4edb6ac4..707ee1da 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift @@ -49,12 +49,6 @@ struct MeshMapContent: MapContent { ForEach(positions, id: \.id) { position in /// Apply favorits filter and don't show ignored nodes if (!showFavorites || (position.nodePosition?.favorite == true)) && !(position.nodePosition?.ignored == true) { - - let nodeColor = UIColor(hex: UInt32(position.nodePosition?.num ?? 0)) - let positionName = position.nodePosition?.user?.longName ?? "?" - // Use a hash of the position ID to stagger animation delays for each node, preventing synchronized animations and improving visual distinction. - let calculatedDelay = Double(position.id.hashValue % 100) / 100.0 * 0.5 - let coordinateForNodePin: CLLocationCoordinate2D = if position.isPreciseLocation { // Precise location: place node pin at actual location. position.coordinate @@ -62,16 +56,6 @@ struct MeshMapContent: MapContent { // Imprecise location: fuzz slightly so overlapping nodes are visible and clickable at highest zoom levels. position.fuzzedCoordinate } - - Annotation(positionName, coordinate: coordinateForNodePin) { - LazyVStack { - AnimatedNodePin( - nodeColor: nodeColor, - shortName: position.nodePosition?.user?.shortName, - hasDetectionSensorMetrics: position.nodePosition?.hasDetectionSensorMetrics ?? false, - isOnline: position.nodePosition?.isOnline ?? false, - calculatedDelay: calculatedDelay - ) if 12...15 ~= position.precisionBits || position.precisionBits == 32 { let nodeColor = UIColor(hex: UInt32(position.nodePosition?.num ?? 0)) @@ -89,7 +73,7 @@ struct MeshMapContent: MapContent { // Use a hash of the position ID to stagger animation delays for each node, preventing synchronized animations and improving visual distinction. let calculatedDelay = Double(position.id.hashValue % 100) / 100.0 * 0.5 - Annotation(positionName, coordinate: position.coordinate) { + Annotation(positionName, coordinate: coordinateForNodePin) { LazyVStack { AnimatedNodePin( nodeColor: nodeColor, From ddb01f5d5ff9e3a7a5df4b8bc6a97cd82366cd24 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 26 Oct 2025 18:40:19 -0700 Subject: [PATCH 09/13] Update Meshtastic/Extensions/CoreData/UserEntityExtension.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Meshtastic/Extensions/CoreData/UserEntityExtension.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Meshtastic/Extensions/CoreData/UserEntityExtension.swift b/Meshtastic/Extensions/CoreData/UserEntityExtension.swift index 48e44e30..1307764a 100644 --- a/Meshtastic/Extensions/CoreData/UserEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/UserEntityExtension.swift @@ -56,7 +56,7 @@ extension UserEntity { guard self.lastMessage != nil || skipLastMessageCheck else { return 0; } let fetchRequest = messageFetchRequest - fetchRequest.sortDescriptors = [] // sort is irrelvant. + fetchRequest.sortDescriptors = [] // sort is irrelevant. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")]) return (try? context.count(for: fetchRequest)) ?? 0 From 428b144701d69b33973744dbea3e39abb2917256 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 26 Oct 2025 18:40:37 -0700 Subject: [PATCH 10/13] Update Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift index 707ee1da..f1fd931f 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift @@ -47,7 +47,7 @@ struct MeshMapContent: MapContent { @MapContentBuilder var positionAnnotations: some MapContent { ForEach(positions, id: \.id) { position in - /// Apply favorits filter and don't show ignored nodes + /// Apply favorites filter and don't show ignored nodes if (!showFavorites || (position.nodePosition?.favorite == true)) && !(position.nodePosition?.ignored == true) { let coordinateForNodePin: CLLocationCoordinate2D = if position.isPreciseLocation { // Precise location: place node pin at actual location. From 4facf1054b88dc2ccaa45a52f27fdfd0a66e5288 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 26 Oct 2025 18:41:13 -0700 Subject: [PATCH 11/13] Update Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift b/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift index 8d7962b4..3d0fc0f1 100644 --- a/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift @@ -38,7 +38,6 @@ extension ChannelEntity { } func unreadMessages(context: NSManagedObjectContext) -> Int { - let context = PersistenceController.shared.container.viewContext let fetchRequest = messageFetchRequest fetchRequest.sortDescriptors = [] // sort is irrelvant. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")]) From 5ecad218c6f8d2685ceb492b468a5b25fcfba70b Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 26 Oct 2025 18:41:26 -0700 Subject: [PATCH 12/13] Update Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift b/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift index 136b64cb..c9e06d88 100644 --- a/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift @@ -30,7 +30,7 @@ extension MyInfoEntity { func unreadMessages(context: NSManagedObjectContext) -> Int { // Returns the count of unread *channel* messages let fetchRequest = messageFetchRequest - fetchRequest.sortDescriptors = [] // sort is irrelvant. + fetchRequest.sortDescriptors = [] // sort is irrelevant. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")]) return (try? context.count(for: fetchRequest)) ?? 0 From 0c3f1bd2d646c48373aa96fd4a3b8faa3a349c32 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 26 Oct 2025 18:41:34 -0700 Subject: [PATCH 13/13] Update Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift b/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift index 3d0fc0f1..5916567c 100644 --- a/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift @@ -39,7 +39,7 @@ extension ChannelEntity { func unreadMessages(context: NSManagedObjectContext) -> Int { let fetchRequest = messageFetchRequest - fetchRequest.sortDescriptors = [] // sort is irrelvant. + fetchRequest.sortDescriptors = [] // sort is irrelevant. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")]) return (try? context.count(for: fetchRequest)) ?? 0