diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 40e9c479..6850954b 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -111,6 +111,9 @@ DDB75A1E2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB75A1D2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift */; }; DDB75A212A12B954006ED576 /* LoRaSignalStrength.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB75A202A12B954006ED576 /* LoRaSignalStrength.swift */; }; DDB75A232A13CDA9006ED576 /* BatteryLevelCompact.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB75A222A13CDA9006ED576 /* BatteryLevelCompact.swift */; }; + DDB8F4102A9EE5B400230ECE /* Messages.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB8F40F2A9EE5B400230ECE /* Messages.swift */; }; + DDB8F4122A9EE5DD00230ECE /* UserList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB8F4112A9EE5DD00230ECE /* UserList.swift */; }; + DDB8F4142A9EE5F000230ECE /* ChannelList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB8F4132A9EE5F000230ECE /* ChannelList.swift */; }; DDC2E15826CE248E0042C5E4 /* MeshtasticApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC2E15726CE248E0042C5E4 /* MeshtasticApp.swift */; }; DDC2E15C26CE248F0042C5E4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDC2E15B26CE248F0042C5E4 /* Assets.xcassets */; }; DDC2E15F26CE248F0042C5E4 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDC2E15E26CE248F0042C5E4 /* Preview Assets.xcassets */; }; @@ -312,6 +315,9 @@ DDB75A1F2A10766D006ED576 /* MeshtasticDataModelV13.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV13.xcdatamodel; sourceTree = ""; }; DDB75A202A12B954006ED576 /* LoRaSignalStrength.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoRaSignalStrength.swift; sourceTree = ""; }; DDB75A222A13CDA9006ED576 /* BatteryLevelCompact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryLevelCompact.swift; sourceTree = ""; }; + DDB8F40F2A9EE5B400230ECE /* Messages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Messages.swift; sourceTree = ""; }; + DDB8F4112A9EE5DD00230ECE /* UserList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserList.swift; sourceTree = ""; }; + DDB8F4132A9EE5F000230ECE /* ChannelList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelList.swift; sourceTree = ""; }; DDBA45EC299ED78100DEEDDC /* MeshtasticDataModelV8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV8.xcdatamodel; sourceTree = ""; }; DDC2E15426CE248E0042C5E4 /* Meshtastic.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Meshtastic.app; sourceTree = BUILT_PRODUCTS_DIR; }; DDC2E15726CE248E0042C5E4 /* MeshtasticApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticApp.swift; sourceTree = ""; }; @@ -702,9 +708,12 @@ DDC2E18B26CE25A70042C5E4 /* Messages */ = { isa = PBXGroup; children = ( - DD882F5C2772E4640005BF05 /* Contacts.swift */, - DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */, + DDB8F4132A9EE5F000230ECE /* ChannelList.swift */, DD798B062915928D005217CD /* ChannelMessageList.swift */, + DD882F5C2772E4640005BF05 /* Contacts.swift */, + DDB8F40F2A9EE5B400230ECE /* Messages.swift */, + DDB8F4112A9EE5DD00230ECE /* UserList.swift */, + DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */, ); path = Messages; sourceTree = ""; @@ -1029,6 +1038,7 @@ DD5394FE276BA0EF00AD86B1 /* PositionEntityExtension.swift in Sources */, DD913639270DFF4C00D7ACF3 /* LocalNotificationManager.swift in Sources */, DDDB444C29F8AAA600EE2349 /* Color.swift in Sources */, + DDB8F4122A9EE5DD00230ECE /* UserList.swift in Sources */, DDB75A0F2A05920E006ED576 /* FileManager.swift in Sources */, DD4F23CD28779A3C001D37CB /* EnvironmentMetricsLog.swift in Sources */, DD41A61529AB0035003C5A37 /* NodeWeatherForecast.swift in Sources */, @@ -1049,6 +1059,7 @@ DD5E5208298EE33B00D21B61 /* rtttl.pb.swift in Sources */, DD6193792863875F00E59241 /* SerialConfig.swift in Sources */, DDA0B6B2294CDC55001356EC /* Channels.swift in Sources */, + DDB8F4102A9EE5B400230ECE /* Messages.swift in Sources */, DD4A911E2708C65400501B7E /* AppSettings.swift in Sources */, DD5E5209298EE33B00D21B61 /* module_config.pb.swift in Sources */, DD2160AF28C5552500C17253 /* MQTTConfig.swift in Sources */, @@ -1068,6 +1079,7 @@ DD3CC6C228EB9D4900FA9159 /* UpdateCoreData.swift in Sources */, DDE0F7C5295F77B700B8AAB3 /* AppSettingsEnums.swift in Sources */, DDB6ABE628B1406100384BA1 /* LoraConfigEnums.swift in Sources */, + DDB8F4142A9EE5F000230ECE /* ChannelList.swift in Sources */, DDD43FE32A78C8900083A3E9 /* MqttClientProxyManager.swift in Sources */, DDDB444629F8A96500EE2349 /* Character.swift in Sources */, DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */, diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV17.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV17.xcdatamodel/contents index ce9aea5b..b5b17a81 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV17.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV17.xcdatamodel/contents @@ -334,6 +334,7 @@ + diff --git a/Meshtastic/Views/ContentView.swift b/Meshtastic/Views/ContentView.swift index 4e2f059e..f99bf226 100644 --- a/Meshtastic/Views/ContentView.swift +++ b/Meshtastic/Views/ContentView.swift @@ -8,7 +8,12 @@ struct ContentView: View { @StateObject var appState = AppState.shared var body: some View { TabView(selection: $appState.tabSelection) { - Contacts() +// Contacts() +// .tabItem { +// Label("messages", systemImage: "message") +// } +// .tag(Tab.contacts) + Messages() .tabItem { Label("messages", systemImage: "message") } diff --git a/Meshtastic/Views/Messages/ChannelList.swift b/Meshtastic/Views/Messages/ChannelList.swift new file mode 100644 index 00000000..956c733b --- /dev/null +++ b/Meshtastic/Views/Messages/ChannelList.swift @@ -0,0 +1,152 @@ +// +// ChannelList.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 8/29/23. +// + +import SwiftUI +import CoreData + +struct ChannelList: View { + + @Environment(\.managedObjectContext) var context + @EnvironmentObject var bleManager: BLEManager + + @State var node: NodeInfoEntity? + + @State private var channelSelection: ChannelEntity? // Nothing selected by default. + @State private var isPresentingDeleteChannelMessagesConfirm: Bool = false + @State private var isPresentingTraceRouteSentAlert = false + + var body: some View { + let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMdd", options: 0, locale: Locale.current) + let dateFormatString = (localeDateFormat ?? "MM/dd/YY") + + NavigationStack { + + List { + // Display Contacts for the rest of the non admin channels + if node != nil && node!.myInfo != nil && node!.myInfo!.channels != nil { + ForEach(node!.myInfo!.channels!.array as! [ChannelEntity], id: \.self) { (channel: ChannelEntity) in + if channel.name?.lowercased() ?? "" != "admin" && channel.name?.lowercased() ?? "" != "gpio" && channel.name?.lowercased() ?? "" != "serial" { + + NavigationLink(destination: ChannelMessageList(channel: channel)) { + + let mostRecent = channel.allPrivateMessages.last(where: { $0.channel == channel.index }) + 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) + .font(.system(size: 10)) + .foregroundColor(.accentColor) + .brightness(0.2) + } + CircleText(text: String(channel.index), color: .accentColor, circleSize: 45, fontSize: 40) + .brightness(0.2) + + VStack(alignment: .leading){ + HStack{ + if channel.name?.isEmpty ?? false { + if channel.role == 1 { + Text(String("PrimaryChannel").camelCaseToWords()) + } else { + Text(String("Channel \(channel.index)").camelCaseToWords()) + } + } else { + Text(String(channel.name ?? "Channel \(channel.index)").camelCaseToWords()) + } + + Spacer() + + if channel.allPrivateMessages.count > 0 { + + if lastMessageDay == currentDay { + Text(lastMessageTime, style: .time ) + .font(.system(size: 16)) + .foregroundColor(.secondary) + } else if lastMessageDay == (currentDay - 1) { + Text("Yesterday") + .font(.system(size: 16)) + .foregroundColor(.secondary) + } else if lastMessageDay < (currentDay - 1) && lastMessageDay > (currentDay - 5) { + Text(lastMessageTime.formattedDate(format: dateFormatString)) + .font(.system(size: 16)) + .foregroundColor(.secondary) + } else if lastMessageDay < (currentDay - 1800) { + Text(lastMessageTime.formattedDate(format: dateFormatString)) + .font(.system(size: 16)) + .foregroundColor(.secondary) + } + } + #if targetEnvironment(macCatalyst) + Image(systemName: "chevron.forward") + .font(.caption) + .foregroundColor(.secondary) + #endif + } + + if channel.allPrivateMessages.count > 0 { + HStack(alignment: .top) { + Text("\(mostRecent != nil ? mostRecent!.messagePayload! : " ")") + .font(.system(size: 16)) + .foregroundColor(.secondary) + } + } + } + } + .frame(height: 62) + .contextMenu { + Button { + channel.mute = !channel.mute + + do { + try context.save() + // Would rather not do this but the merge changes on + // A single object is only working on mac GVH + context.refreshAllObjects() + // context.refresh(channel, mergeChanges: true) + } catch { + context.rollback() + print("💥 Save Channel Mute Error") + } + } label: { + Label(channel.mute ? "Show Alerts" : "Hide Alerts", systemImage: channel.mute ? "bell" : "bell.slash") + } + + if channel.allPrivateMessages.count > 0 { + Button(role: .destructive) { + isPresentingDeleteChannelMessagesConfirm = true + channelSelection = channel + } label: { + Label("Delete Messages", systemImage: "trash") + } + } + } + .confirmationDialog( + "This conversation will be deleted.", + isPresented: $isPresentingDeleteChannelMessagesConfirm, + titleVisibility: .visible + ) { + Button(role: .destructive) { + deleteChannelMessages(channel: channelSelection!, context: context) + context.refresh(node!.myInfo!, mergeChanges: true) + channelSelection = nil + } label: { + Text("delete") + } + } + } + } + .padding([.top, .bottom]) + } + } + .listStyle(.grouped) + .navigationTitle("channels") + } + } +} diff --git a/Meshtastic/Views/Messages/Messages.swift b/Meshtastic/Views/Messages/Messages.swift new file mode 100644 index 00000000..ad173e60 --- /dev/null +++ b/Meshtastic/Views/Messages/Messages.swift @@ -0,0 +1,96 @@ +// +// Messages.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 8/29/23. +// + +import SwiftUI +import CoreData + +struct Messages: View { + + @Environment(\.managedObjectContext) var context + @EnvironmentObject var bleManager: BLEManager + + @FetchRequest( + sortDescriptors: [NSSortDescriptor(key: "lastMessage", ascending: false), NSSortDescriptor(key: "longName", ascending: true)], + animation: .default) + + private var users: FetchedResults + @State var node: NodeInfoEntity? + @State private var userSelection: UserEntity? // Nothing selected by default. + @State private var channelSelection: ChannelEntity? // Nothing selected by default. + @State private var isPresentingDeleteChannelMessagesConfirm: Bool = false + @State private var isPresentingDeleteUserMessagesConfirm: Bool = false + @State private var isPresentingTraceRouteSentAlert = false + + @State private var columnVisibility = NavigationSplitViewVisibility.all + + enum MessagesSidebar { + case groupMessages + case directMessages + } + + var body: some View { + + NavigationSplitView(columnVisibility: $columnVisibility) { + // Sidebar + List { + NavigationLink { + ChannelList(node: node) + } label: { + Image(systemName: "person.3") + .symbolRenderingMode(.hierarchical) + .foregroundColor(.accentColor) + .brightness(0.2) + Text("channels") + .font(.title2) + } + NavigationLink { + UserList(node: node) + } label: { + Image(systemName: "person") + .symbolRenderingMode(.hierarchical) + .foregroundColor(.accentColor) + .brightness(0.2) + Text("direct.messages") + .font(.title2) + } + } + .navigationTitle("messages") + .navigationBarItems(leading: MeshtasticLogo()) + .onAppear { + self.bleManager.context = context + if UserDefaults.preferredPeripheralId.count > 0 { + let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(bleManager.connectedPeripheral?.num ?? -1)) + do { + guard let fetchedNode = try context.fetch(fetchNodeInfoRequest) as? [NodeInfoEntity] else { + return + } + // Found a node, check it for a region + if !fetchedNode.isEmpty { + node = fetchedNode[0] + } + } catch { + + } + } + } + + } content: { + + ChannelList() + UserList() + + } detail: { + if let user = userSelection { + UserMessageList(user: user) + + } else { + Text("select.contact") + } + } + } +} diff --git a/Meshtastic/Views/Messages/UserList.swift b/Meshtastic/Views/Messages/UserList.swift new file mode 100644 index 00000000..cbe41f6c --- /dev/null +++ b/Meshtastic/Views/Messages/UserList.swift @@ -0,0 +1,167 @@ +// +// UserList.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 8/29/23. +// + +import SwiftUI +import CoreData + +struct UserList: View { + + @Environment(\.managedObjectContext) var context + @EnvironmentObject var bleManager: BLEManager + + @FetchRequest( + sortDescriptors: [NSSortDescriptor(key: "lastMessage", ascending: false), NSSortDescriptor(key: "longName", ascending: true)], + animation: .default) + + private var users: FetchedResults + @State var node: NodeInfoEntity? + @State private var userSelection: UserEntity? // Nothing selected by default. + @State private var isPresentingDeleteUserMessagesConfirm: Bool = false + @State private var isPresentingTraceRouteSentAlert = false + + var body: some View { + let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMdd", options: 0, locale: Locale.current) + let dateFormatString = (localeDateFormat ?? "MM/dd/YY") + List { + + + ForEach(users) { (user: UserEntity) in + + let mostRecent = user.messageList.last + 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 + if user.num != bleManager.connectedPeripheral?.num ?? 0 { + + NavigationLink(destination: UserMessageList(user: user)) { + ZStack { + Image(systemName: "circle.fill") + .opacity(user.unreadMessages > 0 ? 1 : 0) + .font(.system(size: 10)) + .foregroundColor(.accentColor) + .brightness(0.2) + } + + CircleText(text: user.shortName ?? "???", color: Color(UIColor(hex: UInt32(user.num))), circleSize: 45, fontSize: (user.shortName ?? "???").isEmoji() ? 32 : (user.shortName?.count ?? 0 == 4 ? 14 : (user.shortName?.count ?? 0 == 3 ? 18 : 22)), brightness: 0.0, textColor: UIColor(hex: UInt32(user.num)).isLight() ? .black : .white) + + VStack(alignment: .leading){ + HStack{ + Text(user.longName ?? "unknown".localized) + + Spacer() + + if user.messageList.count > 0 { + if lastMessageDay == currentDay { + Text(lastMessageTime, style: .time ) + .font(.system(size: 16)) + .foregroundColor(.secondary) + } else if lastMessageDay == (currentDay - 1) { + Text("Yesterday") + .font(.system(size: 16)) + .foregroundColor(.secondary) + } else if lastMessageDay < (currentDay - 1) && lastMessageDay > (currentDay - 5) { + Text(lastMessageTime.formattedDate(format: dateFormatString)) + .font(.system(size: 16)) + .foregroundColor(.secondary) + } else if lastMessageDay < (currentDay - 1800) { + Text(lastMessageTime.formattedDate(format: dateFormatString)) + .font(.system(size: 16)) + .foregroundColor(.secondary) + } + } + #if targetEnvironment(macCatalyst) + Image(systemName: "chevron.forward") + .font(.caption) + .foregroundColor(.secondary) + #endif + } + + if user.messageList.count > 0 { + HStack(alignment: .top) { + Text("\(mostRecent != nil ? mostRecent!.messagePayload! : " ")") + .font(.system(size: 16)) + .foregroundColor(.secondary) + } + } + } + } + .frame(height: 62) + .contextMenu { + Button { + user.mute = !user.mute + do { + try context.save() + } catch { + context.rollback() + print("💥 Save User Mute Error") + } + } label: { + Label(user.mute ? "Show Alerts" : "Hide Alerts", systemImage: user.mute ? "bell" : "bell.slash") + } + Button { + let success = bleManager.sendTraceRouteRequest(destNum: user.num, wantResponse: true) + if success { + isPresentingTraceRouteSentAlert = true + } + } label: { + Label("Trace Route", systemImage: "signpost.right.and.left") + } + if user.messageList.count > 0 { + Button(role: .destructive) { + isPresentingDeleteUserMessagesConfirm = true + userSelection = user + } label: { + Label("Delete Messages", systemImage: "trash") + } + } + } + .alert( + "Trace Route Sent", + isPresented: $isPresentingTraceRouteSentAlert + ) { + Button("OK", role: .cancel) { } + } + message: { + Text("This could take a while, response will appear in the mesh log.") + } + .confirmationDialog( + "This conversation will be deleted.", + isPresented: $isPresentingDeleteUserMessagesConfirm, + titleVisibility: .visible + ) { + Button(role: .destructive) { + deleteUserMessages(user: userSelection!, context: context) + context.refresh(node!.user!, mergeChanges: true) + } label: { + Text("delete") + } + } + } + } + } + .listStyle(.grouped) + .navigationTitle("contacts") + .onAppear { + self.bleManager.context = context + if UserDefaults.preferredPeripheralId.count > 0 { + let fetchNodeInfoRequest: NSFetchRequest = NSFetchRequest.init(entityName: "NodeInfoEntity") + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(bleManager.connectedPeripheral?.num ?? -1)) + do { + guard let fetchedNode = try context.fetch(fetchNodeInfoRequest) as? [NodeInfoEntity] else { + return + } + // Found a node, check it for a region + if !fetchedNode.isEmpty { + node = fetchedNode[0] + } + } catch { + + } + } + } + } +}