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 <garth@meshtastic.com>
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 e0f0b4a0f7 (ChannelMessageList and UserMessageList: scroll to bottom onFirstAppear)

* Revert "ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification"

This reverts commit ee1a7c4415.

---------

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 <jake-b@users.noreply.github.com>
Co-authored-by: Mike Robbins <mrobbins@alum.mit.edu>
This commit is contained in:
Garth Vander Houwen 2025-10-17 18:16:00 -07:00 committed by GitHub
parent ad25342d88
commit 69c318a9e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 302 additions and 139 deletions

View file

@ -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",

View file

@ -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

View file

@ -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 {

View file

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

View file

@ -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<MessageEntity> {
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

View file

@ -6,23 +6,39 @@
//
import Foundation
import CoreData
extension MyInfoEntity {
var messagePredicate: NSPredicate {
return NSPredicate(format: "toUser == nil AND isEmoji == false")
}
var messageFetchRequest: NSFetchRequest<MessageEntity> {
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

View file

@ -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<MessageEntity> {
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 }

View file

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

View file

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

View file

@ -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 {

View file

@ -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

View file

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

View file

@ -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])

View file

@ -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

View file

@ -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<MessageEntity>
init(user: UserEntity) {
self.user = user
// Configure fetch request here
let request: NSFetchRequest<MessageEntity> = 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

View file

@ -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())

View file

@ -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> = NodeInfoEntity.fetchRequest()
@ -154,7 +154,7 @@ fileprivate struct FilteredNodeList: View {
@EnvironmentObject var accessoryManager: AccessoryManager
@FetchRequest private var nodes: FetchedResults<NodeInfoEntity>
@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

View file

@ -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()

View file

@ -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) {

View file

@ -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