diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index f1e6046b..c6bef2b3 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -14,6 +14,11 @@ B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E905B02B71F7F300654D07 /* TextMessageField.swift */; }; C9697F9D279336B700250207 /* LocalMBTileOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9697F9C279336B700250207 /* LocalMBTileOverlay.swift */; }; C9697FA527933B8C00250207 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = C9697FA427933B8C00250207 /* SQLite */; }; + D93068D32B8129510066FBC8 /* MessageContextMenuItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */; }; + D93068D52B812B700066FBC8 /* MessageDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D42B812B700066FBC8 /* MessageDestination.swift */; }; + D93068D72B8146690066FBC8 /* MessageText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D62B8146690066FBC8 /* MessageText.swift */; }; + D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D82B81509C0066FBC8 /* TapbackResponses.swift */; }; + D9BC22DB2B7DE8E2006A37D5 /* TileDownloadStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BC22DA2B7DE8E2006A37D5 /* TileDownloadStatus.swift */; }; D9C9839D2B79CFD700BDBE6A /* TextMessageSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C9839C2B79CFD700BDBE6A /* TextMessageSize.swift */; }; D9C983A02B79D0E800BDBE6A /* AlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C9839F2B79D0E800BDBE6A /* AlertButton.swift */; }; D9C983A22B79D1A600BDBE6A /* RequestPositionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C983A12B79D1A600BDBE6A /* RequestPositionButton.swift */; }; @@ -234,6 +239,11 @@ B399E8A32B6F486400E4488E /* RetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryButton.swift; sourceTree = ""; }; B3E905B02B71F7F300654D07 /* TextMessageField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMessageField.swift; sourceTree = ""; }; C9697F9C279336B700250207 /* LocalMBTileOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMBTileOverlay.swift; sourceTree = ""; }; + D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageContextMenuItems.swift; sourceTree = ""; }; + D93068D42B812B700066FBC8 /* MessageDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDestination.swift; sourceTree = ""; }; + D93068D62B8146690066FBC8 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = ""; }; + D93068D82B81509C0066FBC8 /* TapbackResponses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapbackResponses.swift; sourceTree = ""; }; + D9BC22DA2B7DE8E2006A37D5 /* TileDownloadStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileDownloadStatus.swift; sourceTree = ""; }; D9C9839C2B79CFD700BDBE6A /* TextMessageSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMessageSize.swift; sourceTree = ""; }; D9C9839F2B79D0E800BDBE6A /* AlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertButton.swift; sourceTree = ""; }; D9C983A12B79D1A600BDBE6A /* RequestPositionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestPositionButton.swift; sourceTree = ""; }; @@ -494,6 +504,7 @@ DD964FC32974767D007C176F /* MapViewFitExtension.swift */, DD2AD8A7296D2DF9001FF0E7 /* MapViewSwiftUI.swift */, DDDB443529F6287000EE2349 /* MapButtons.swift */, + D9BC22DA2B7DE8E2006A37D5 /* TileDownloadStatus.swift */, ); path = Custom; sourceTree = ""; @@ -690,6 +701,7 @@ DD1925B828CDA93900720036 /* SerialConfigEnums.swift */, DD994B68295F88B60013760A /* IntervalEnums.swift */, DD5E5239298EFA5300D21B61 /* TelemetryWeather.swift */, + D93068D42B812B700066FBC8 /* MessageDestination.swift */, ); path = Enums; sourceTree = ""; @@ -847,6 +859,9 @@ DDB8F4112A9EE5DD00230ECE /* UserList.swift */, DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */, B399E8A32B6F486400E4488E /* RetryButton.swift */, + D93068D62B8146690066FBC8 /* MessageText.swift */, + D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */, + D93068D82B81509C0066FBC8 /* TapbackResponses.swift */, ); path = Messages; sourceTree = ""; @@ -1239,6 +1254,7 @@ DD94B7402ACCE3BE00DCD1D1 /* MapSettingsForm.swift in Sources */, DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */, 6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */, + D93068D32B8129510066FBC8 /* MessageContextMenuItems.swift in Sources */, DD5E520A298EE33B00D21B61 /* channel.pb.swift in Sources */, DD8EBF43285058FA00426DCA /* DisplayConfig.swift in Sources */, DD964FC42974767D007C176F /* MapViewFitExtension.swift in Sources */, @@ -1253,6 +1269,7 @@ DDB6ABE028B13AC700384BA1 /* DeviceEnums.swift in Sources */, DD86D40C287F401000BAEB7A /* SaveChannelQRCode.swift in Sources */, DDA1C48E28DB49D3009933EC /* ChannelRoles.swift in Sources */, + D9BC22DB2B7DE8E2006A37D5 /* TileDownloadStatus.swift in Sources */, DD8ED9C8289CE4B900B3B0AB /* RoutingError.swift in Sources */, DD5E5202298EE33B00D21B61 /* admin.pb.swift in Sources */, DDC1B81A2AB5377B00C71E39 /* MessagesTips.swift in Sources */, @@ -1277,6 +1294,7 @@ DD6193752862F6E600E59241 /* ExternalNotificationConfig.swift in Sources */, DDB6ABE428B13FFF00384BA1 /* DisplayEnums.swift in Sources */, DD4975A52B147BA90026544E /* AmbientLightingConfig.swift in Sources */, + D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */, DD86D40A287F04F100BAEB7A /* InvalidVersion.swift in Sources */, DDD94A502845C8F5004A87A0 /* DateTimeText.swift in Sources */, DD5E5212298EE33B00D21B61 /* apponly.pb.swift in Sources */, @@ -1294,6 +1312,7 @@ DD5E523A298EFA5300D21B61 /* TelemetryWeather.swift in Sources */, DDAB580D2B0DAA9E00147258 /* Routes.swift in Sources */, C9697F9D279336B700250207 /* LocalMBTileOverlay.swift in Sources */, + D93068D52B812B700066FBC8 /* MessageDestination.swift in Sources */, DD58C5F22919AD3C00D5BEFB /* ChannelEntityExtension.swift in Sources */, DDDB444E29F8AB0E00EE2349 /* Int.swift in Sources */, DD0F791B28713C8A00A6FDAD /* AdminMessageList.swift in Sources */, @@ -1312,6 +1331,7 @@ DDAB580F2B0DAFBC00147258 /* LocationEntityExtension.swift in Sources */, B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */, DD5E5211298EE33B00D21B61 /* remote_hardware.pb.swift in Sources */, + D93068D72B8146690066FBC8 /* MessageText.swift in Sources */, DD5E5204298EE33B00D21B61 /* xmodem.pb.swift in Sources */, DDE5B4062B227E3200FCDD05 /* TraceRouteEntityExtension.swift in Sources */, DDC2E15826CE248E0042C5E4 /* MeshtasticApp.swift in Sources */, diff --git a/Meshtastic/Enums/MessageDestination.swift b/Meshtastic/Enums/MessageDestination.swift new file mode 100644 index 00000000..4b6d2b54 --- /dev/null +++ b/Meshtastic/Enums/MessageDestination.swift @@ -0,0 +1,19 @@ +/// Helper abstraction for sharing functionality between channel and direct messaging. +enum MessageDestination { + case user(UserEntity) + case channel(ChannelEntity) + + var userNum: Int64 { + switch self { + case let .user(user): return user.num + case .channel: return 0 + } + } + + var channelNum: Int32 { + switch self { + case .user: return 0 + case let .channel(channel): return channel.index + } + } +} diff --git a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift index 5f3d76ef..81e106b8 100644 --- a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift @@ -17,4 +17,8 @@ extension MessageEntity { let time = messageTimestamp <= 0 ? receivedTimestamp : messageTimestamp return Date(timeIntervalSince1970: TimeInterval(time)) } + + var canRetry: Bool { + return ackError == 9 || ackError == 5 || ackError == 3 + } } diff --git a/Meshtastic/Helpers/Map/OfflineTileManager.swift b/Meshtastic/Helpers/Map/OfflineTileManager.swift index ab973c1e..e3250e12 100644 --- a/Meshtastic/Helpers/Map/OfflineTileManager.swift +++ b/Meshtastic/Helpers/Map/OfflineTileManager.swift @@ -11,6 +11,14 @@ import MapKit class OfflineTileManager: ObservableObject { static let shared = OfflineTileManager() + // MARK: - Public properties + + @Published var status: DownloadStatus = .downloaded + + enum DownloadStatus { + case downloaded, downloading + } + init() { print("Documents Directory = \(documentsDirectory)") createDirectoriesIfNecessary() @@ -46,6 +54,10 @@ class OfflineTileManager: ObservableObject { do { return try Data(contentsOf: tilesUrl) } catch let error as NSError where error.code == NSFileReadNoSuchFileError { + DispatchQueue.main.async { self.status = .downloading } + defer { + DispatchQueue.main.async { self.status = .downloaded } + } let data = try Data(contentsOf: overlay.url(forTilePath: path)) try data.write(to: tilesUrl) return data diff --git a/Meshtastic/Views/MapKitMap/Custom/TileDownloadStatus.swift b/Meshtastic/Views/MapKitMap/Custom/TileDownloadStatus.swift new file mode 100644 index 00000000..07cddcb8 --- /dev/null +++ b/Meshtastic/Views/MapKitMap/Custom/TileDownloadStatus.swift @@ -0,0 +1,14 @@ +import SwiftUI + +struct TileDownloadStatus: View { + @ObservedObject var tileManager = OfflineTileManager.shared + + var body: some View { + if tileManager.status == .downloading { + Image(systemName: "arrow.down.circle.fill") + .foregroundColor(.gray) + } else { + EmptyView() + } + } +} diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index 8c311d7c..d645b613 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -18,15 +18,11 @@ struct ChannelMessageList: View { @ObservedObject var myInfo: MyInfoEntity @ObservedObject var channel: ChannelEntity - @State var showDeleteMessageAlert = false - @State private var deleteMessageId: Int64 = 0 @State private var replyMessageId: Int64 = 0 @AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1 var body: some View { VStack { - let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmmssa", options: 0, locale: Locale.current) - let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mm:ss a") ScrollViewReader { scrollView in ScrollView { LazyVStack { @@ -55,155 +51,28 @@ struct ChannelMessageList: View { .offset(y: -5) } VStack(alignment: currentUser ? .trailing : .leading) { - let markdownText: LocalizedStringKey = LocalizedStringKey.init(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE")) - let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */ let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue) - Text(markdownText) - .tint(linkBlue) - .padding(10) - .foregroundColor(.white) - .background(currentUser ? .accentColor : Color(.gray)) - .cornerRadius(15) - .overlay( - VStack { - if #available(iOS 17.0, macOS 14.0, *) { - isDetectionSensorMessage ? Image(systemName: "sensor.fill") - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .foregroundStyle(Color.orange) - .symbolRenderingMode(.multicolor) - .symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3)) - .offset(x: 20, y: -20) - : nil - } else { - isDetectionSensorMessage ? Image(systemName: "sensor.fill") - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .foregroundStyle(Color.orange) - .offset(x: 20, y: -20) - : nil - } - } - ) - .contextMenu { - VStack { - Text("channel")+Text(": \(message.channel)") - } - Menu("tapback") { - ForEach(Tapbacks.allCases) { tb in - Button(action: { - if bleManager.sendMessage(message: tb.emojiString, toUserNum: 0, channel: channel.index, isEmoji: true, replyID: message.messageId) { - print("Sent \(tb.emojiString) Tapback") - self.context.refresh(channel, mergeChanges: true) - } else { print("\(tb.emojiString) Tapback Failed") } - }) { - Text(tb.description) - let image = tb.emojiString.image() - Image(uiImage: image!) - } - } - } - Button(action: { - self.replyMessageId = message.messageId - self.messageFieldFocused = true - print("I want to reply to \(message.messageId)") - }) { - Text("reply") - Image(systemName: "arrowshape.turn.up.left.2.fill") - } - Button(action: { - UIPasteboard.general.string = message.messagePayload - }) { - Text("copy") - Image(systemName: "doc.on.doc") - } - Menu("message.details") { - VStack { - let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp)) - Text(" \(messageDate.formattedDate(format: dateFormatString))").foregroundColor(.gray) - } - if !currentUser { - VStack { - Text("SNR \(String(format: "%.2f", message.snr)) dB") - } - } - if currentUser && message.receivedACK { - VStack { - Text("received.ack")+Text(" \(message.receivedACK ? "✔️" : "")") - } - } else if currentUser && message.ackError == 0 { - // Empty Error - Text("waiting") - } else if currentUser && message.ackError > 0 { - let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) - Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true) - } - if currentUser { - VStack { - let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp)) - let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date()) - if ackDate >= sixMonthsAgo! { - Text("Ack Time: \(ackDate.formattedDate(format: "h:mm:ss a"))").foregroundColor(.gray) - } else { - Text("unknown.age").foregroundColor(.gray) - } - } - } - if message.ackSNR != 0 { - VStack { - Text("Ack SNR: \(String(format: "%.2f", message.ackSNR)) dB") - .foregroundColor(.gray) - } - } - } - Divider() - Button(role: .destructive, action: { - self.showDeleteMessageAlert = true - self.deleteMessageId = message.messageId - print(deleteMessageId) - }) { - Text("delete") - Image(systemName: "trash") - } + HStack { + MessageText( + message: message, + tapBackDestination: .channel(channel), + isCurrentUser: currentUser + ) { + self.replyMessageId = message.messageId + self.messageFieldFocused = true } - let tapbacks = message.value(forKey: "tapbacks") as? [MessageEntity] ?? [] - if tapbacks.count > 0 { - VStack(alignment: .trailing) { - HStack { - ForEach( tapbacks ) { (tapback: MessageEntity) in - VStack { - let image = tapback.messagePayload!.image(fontSize: 20) - Image(uiImage: image!).font(.caption) - Text("\(tapback.fromUser?.shortName ?? "?")") - .font(.caption2) - .foregroundColor(.gray) - .fixedSize() - .padding(.bottom, 1) - } - .onAppear { - if !tapback.read { - tapback.read = true - do { - try context.save() - print("📖 Read message \(message.messageId) ") - appState.unreadChannelMessages = myInfo.unreadMessages - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages - context.refresh(myInfo, mergeChanges: true) - } catch { - print("Failed to read tapback \(tapback.messageId)") - } - } - } - } - } - .padding(10) - .overlay( - RoundedRectangle(cornerRadius: 18) - .stroke(Color.gray, lineWidth: 1) - ) + + if currentUser && message.canRetry { + RetryButton(message: message) } } + + TapbackResponses(message: message) { + appState.unreadChannelMessages = myInfo.unreadMessages + UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages + context.refresh(myInfo, mergeChanges: true) + } HStack { if currentUser && message.receivedACK { @@ -218,17 +87,13 @@ struct ChannelMessageList: View { .font(.caption2).foregroundColor(.red) } else if isDetectionSensorMessage { let messageDate = message.timestamp - Text(" \(messageDate.formattedDate(format: dateFormatString))").font(.caption2).foregroundColor(.gray) + Text(" \(messageDate.formattedDate(format: MessageText.dateFormatString))").font(.caption2).foregroundColor(.gray) } } } .padding(.bottom) .id(channel.allPrivateMessages.firstIndex(of: message)) - if currentUser && (message.ackError == 9 || message.ackError == 5 || message.ackError == 3) { - RetryButton(message: message) - } - if !currentUser { Spacer(minLength: 50) } @@ -236,21 +101,6 @@ struct ChannelMessageList: View { .padding([.leading, .trailing]) .frame(maxWidth: .infinity) .id(message.messageId) - .alert(isPresented: $showDeleteMessageAlert) { - Alert(title: Text("Are you sure you want to delete this message?"), message: Text("This action is permanent."), primaryButton: .destructive(Text("Delete")) { - print("OK button tapped") - if deleteMessageId > 0 { - let message = channel.allPrivateMessages.first(where: { $0.messageId == deleteMessageId }) - context.delete(message!) - do { - try context.save() - deleteMessageId = 0 - } catch { - print("Failed to delete message \(deleteMessageId)") - } - } - }, secondaryButton: .cancel()) - } .onAppear { if !message.read { message.read = true @@ -286,7 +136,7 @@ struct ChannelMessageList: View { } TextMessageField( - destination: .channel(channel.index), + destination: .channel(channel), replyMessageId: $replyMessageId, isFocused: $messageFieldFocused ) { diff --git a/Meshtastic/Views/Messages/MessageContextMenuItems.swift b/Meshtastic/Views/Messages/MessageContextMenuItems.swift new file mode 100644 index 00000000..d98eaeec --- /dev/null +++ b/Meshtastic/Views/Messages/MessageContextMenuItems.swift @@ -0,0 +1,115 @@ +import SwiftUI +import CoreData + +struct MessageContextMenuItems: View { + @Environment(\.managedObjectContext) var context + @EnvironmentObject var bleManager: BLEManager + + let message: MessageEntity + let tapBackDestination: MessageDestination + let isCurrentUser: Bool + @Binding var isShowingDeleteConfirmation: Bool + let onReply: () -> Void + + var body: some View { + VStack { + Text("channel") + Text(": \(message.channel)") + } + + Menu("tapback") { + ForEach(Tapbacks.allCases) { tb in + Button { + let sentMessage = bleManager.sendMessage( + message: tb.emojiString, + toUserNum: tapBackDestination.userNum, + channel: tapBackDestination.channelNum, + isEmoji: true, + replyID: message.messageId + ) + if sentMessage { + self.context.refresh(tapBackDestination.managedObject, mergeChanges: true) + } + } label: { + Text(tb.description) + Image(uiImage: tb.emojiString.image()!) + } + } + } + + Button(action: onReply) { + Text("reply") + Image(systemName: "arrowshape.turn.up.left") + } + + Button { + UIPasteboard.general.string = message.messagePayload + } label: { + Text("copy") + Image(systemName: "doc.on.doc") + } + + Menu("message.details") { + VStack { + let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp)) + Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))").foregroundColor(.gray) + } + if !isCurrentUser { + VStack { + Text("SNR \(String(format: "%.2f", message.snr)) dB") + } + } + if isCurrentUser && message.receivedACK { + VStack { + Text("received.ack") + Text(": \(message.receivedACK ? "✔️" : "")") + Text("received.ack.real") + Text(": \(message.realACK ? "✔️" : "")") + } + } else if isCurrentUser && message.ackError == 0 { + // Empty Error + Text("waiting") + } else if isCurrentUser && message.ackError > 0 { + let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) + Text("\(ackErrorVal?.display ?? "Empty Ack Error")") + .fixedSize(horizontal: false, vertical: true) + } + if isCurrentUser { + VStack { + let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp)) + let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date()) + if ackDate >= sixMonthsAgo! { + Text("Ack Time: \(ackDate.formattedDate(format: "h:mm:ss.SSSS a"))") + .foregroundColor(.gray) + } else { + Text("unknown.age") + .font(.caption2) + .foregroundColor(.gray) + } + } + } + if message.ackSNR != 0 { + VStack { + Text("Ack SNR: \(String(format: "%.2f", message.ackSNR)) dB") + .font(.caption2) + .foregroundColor(.gray) + } + } + } + + Divider() + + Button(role: .destructive) { + isShowingDeleteConfirmation = true + } label: { + Text("delete") + Image(systemName: "trash") + } + } +} + +private extension MessageDestination { + var managedObject: NSManagedObject { + switch self { + case let .user(user): return user + case let .channel(channel): return channel + } + } +} diff --git a/Meshtastic/Views/Messages/MessageText.swift b/Meshtastic/Views/Messages/MessageText.swift new file mode 100644 index 00000000..67500bc2 --- /dev/null +++ b/Meshtastic/Views/Messages/MessageText.swift @@ -0,0 +1,89 @@ +import SwiftUI + +struct MessageText: View { + static let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */ + static let localeDateFormat = DateFormatter.dateFormat( + fromTemplate: "yyMMddjmmssa", + options: 0, + locale: Locale.current + ) + static let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mm:ss:a") + + @Environment(\.managedObjectContext) var context + + let message: MessageEntity + let tapBackDestination: MessageDestination + let isCurrentUser: Bool + let onReply: () -> Void + + @State private var isShowingDeleteConfirmation = false + + var body: some View { + let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE")) + return Text(markdownText) + .tint(Self.linkBlue) + .padding(10) + .foregroundColor(.white) + .background(isCurrentUser ? .accentColor : Color(.gray)) + .cornerRadius(15) + .overlay { + let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue) + if tapBackDestination.overlaySensorMessage { + VStack { + if #available(iOS 17.0, macOS 14.0, *) { + isDetectionSensorMessage ? Image(systemName: "sensor.fill") + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .foregroundStyle(Color.orange) + .symbolRenderingMode(.multicolor) + .symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3)) + .offset(x: 20, y: -20) + : nil + } else { + isDetectionSensorMessage ? Image(systemName: "sensor.fill") + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .foregroundStyle(Color.orange) + .offset(x: 20, y: -20) + : nil + } + } + } else { + EmptyView() + } + } + .contextMenu { + MessageContextMenuItems( + message: message, + tapBackDestination: tapBackDestination, + isCurrentUser: isCurrentUser, + isShowingDeleteConfirmation: $isShowingDeleteConfirmation, + onReply: onReply + ) + } + .confirmationDialog( + "Are you sure you want to delete this message?", + isPresented: $isShowingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete Message", role: .destructive) { + context.delete(message) + do { + try context.save() + } catch { + print("Failed to delete message \(message.messageId)") + } + } + Button("Cancel", role: .cancel) {} + } + } +} + +private extension MessageDestination { + var overlaySensorMessage: Bool { + switch self { + case .user: return false + case .channel: return true + } + } +} diff --git a/Meshtastic/Views/Messages/TapbackResponses.swift b/Meshtastic/Views/Messages/TapbackResponses.swift new file mode 100644 index 00000000..a7685697 --- /dev/null +++ b/Meshtastic/Views/Messages/TapbackResponses.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct TapbackResponses: View { + @Environment(\.managedObjectContext) var context + + let message: MessageEntity + let onRead: () -> Void + + @ViewBuilder + var body: some View { + let tapbacks = message.value(forKey: "tapbacks") as? [MessageEntity] ?? [] + if !tapbacks.isEmpty { + VStack(alignment: .trailing) { + HStack { + ForEach( tapbacks ) { (tapback: MessageEntity) in + VStack { + let image = tapback.messagePayload!.image(fontSize: 20) + Image(uiImage: image!).font(.caption) + Text("\(tapback.fromUser?.shortName ?? "?")") + .font(.caption2) + .foregroundColor(.gray) + .fixedSize() + .padding(.bottom, 1) + } + .onAppear { + guard !tapback.read else { + return + } + + tapback.read = true + do { + try context.save() + print("📖 Read tapback \(tapback.messageId) ") + onRead() + } catch { + print("Failed to read tapback \(tapback.messageId)") + } + } + } + } + .padding(10) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(Color.gray, lineWidth: 1) + ) + } + } + } +} diff --git a/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift b/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift index 67175642..e5cd01d3 100644 --- a/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift +++ b/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift @@ -4,15 +4,10 @@ struct TextMessageField: View { static let maxbytes = 228 @EnvironmentObject var bleManager: BLEManager - let destination: Destination + let destination: MessageDestination @Binding var replyMessageId: Int64 @FocusState.Binding var isFocused: Bool let onSubmit: () -> Void - - enum Destination { - case user(Int64) - case channel(Int32) - } @State private var typingMessage: String = "" @State private var totalBytes = 0 @@ -125,7 +120,7 @@ struct TextMessageField: View { } } -private extension TextMessageField.Destination { +private extension MessageDestination { var positionShareMessage: String { switch self { case .user: return "has shared their position and requested a response with your position" @@ -133,23 +128,9 @@ private extension TextMessageField.Destination { } } - var userNum: Int64 { - switch self { - case let .user(num): return num - case .channel: return 0 - } - } - - var channelNum: Int32 { - switch self { - case .user: return 0 - case let .channel(num): return num - } - } - var positionDestNum: Int64 { switch self { - case let .user(num): return num + case let .user(user): return user.num case .channel: return Int64(BLEManager.emptyNodeNum) } } diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index b595576f..7816167d 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -17,14 +17,10 @@ struct UserMessageList: View { @FocusState var messageFieldFocused: Bool // View State Items @ObservedObject var user: UserEntity - @State var showDeleteMessageAlert = false - @State private var deleteMessageId: Int64 = 0 @State private var replyMessageId: Int64 = 0 var body: some View { VStack { - let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmmss", options: 0, locale: Locale.current) - let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mm:ss:a") ScrollViewReader { scrollView in ScrollView { LazyVStack { @@ -50,138 +46,26 @@ struct UserMessageList: View { HStack(alignment: .top) { if currentUser { Spacer(minLength: 50) } VStack(alignment: currentUser ? .trailing : .leading) { - let markdownText: LocalizedStringKey = LocalizedStringKey.init(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE")) - - let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */ - Text(markdownText) - .tint(linkBlue) - .padding(10) - .foregroundColor(.white) - .background(currentUser ? .accentColor : Color(.gray)) - .cornerRadius(15) - .contextMenu { - VStack { - Text("channel")+Text(": \(message.channel)") - } - Menu("tapback") { - ForEach(Tapbacks.allCases) { tb in - Button(action: { - if bleManager.sendMessage(message: tb.emojiString, toUserNum: user.num, channel: 0, isEmoji: true, replyID: message.messageId) { - print("Sent \(tb.emojiString) Tapback") - self.context.refresh(user, mergeChanges: true) - } else { print("\(tb.emojiString) Tapback Failed") } - - }) { - Text(tb.description) - let image = tb.emojiString.image() - Image(uiImage: image!) - } - } - } - Button(action: { - self.replyMessageId = message.messageId - self.messageFieldFocused = true - print("I want to reply to \(message.messageId)") - }) { - Text("reply") - Image(systemName: "arrowshape.turn.up.left.2.fill") - } - Button(action: { - UIPasteboard.general.string = message.messagePayload - }) { - Text("copy") - Image(systemName: "doc.on.doc") - } - Menu("message.details") { - VStack { - - let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp)) - Text("\(messageDate.formattedDate(format: dateFormatString))").foregroundColor(.gray) - } - if !currentUser { - VStack { - Text("SNR \(String(format: "%.2f", message.snr)) dB") - } - } - if currentUser && message.receivedACK { - VStack { - Text("received.ack")+Text(" \(message.receivedACK ? "✔️" : "")") - Text("received.ack.real")+Text(" \(message.realACK ? "✔️" : "")") - } - } else if currentUser && message.ackError == 0 { - // Empty Error - Text("waiting") - } else if currentUser && message.ackError > 0 { - let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) - Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true) - } - if currentUser { - VStack { - let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp)) - let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date()) - if ackDate >= sixMonthsAgo! { - Text("Ack Time: \(ackDate.formattedDate(format: "h:mm:ss.SSSS a"))").foregroundColor(.gray) - } else { - Text("unknown.age").font(.caption2).foregroundColor(.gray) - } - } - } - if message.ackSNR != 0 { - VStack { - Text("Ack SNR: \(String(format: "%.2f", message.ackSNR)) dB") - .font(.caption2) - .foregroundColor(.gray) - } - } - } - Divider() - Button(role: .destructive, action: { - self.showDeleteMessageAlert = true - self.deleteMessageId = message.messageId - print(deleteMessageId) - }) { - Text("delete") - Image(systemName: "trash") - } + HStack { + MessageText( + message: message, + tapBackDestination: .user(user), + isCurrentUser: currentUser + ) { + self.replyMessageId = message.messageId + self.messageFieldFocused = true } - let tapbacks = message.value(forKey: "tapbacks") as? [MessageEntity] ?? [] - if tapbacks.count > 0 { - VStack(alignment: .trailing) { - HStack { - ForEach( tapbacks ) { (tapback: MessageEntity) in - VStack { - let image = tapback.messagePayload!.image(fontSize: 20) - Image(uiImage: image!).font(.caption) - Text("\(tapback.fromUser?.shortName ?? "?")") - .font(.caption2) - .foregroundColor(.gray) - .fixedSize() - .padding(.bottom, 1) - } - .onAppear { - if !tapback.read { - tapback.read = true - do { - try context.save() - print("📖 Read tapback \(tapback.messageId) ") - appState.unreadDirectMessages = user.unreadMessages - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages - - } catch { - print("Failed to read tapback \(tapback.messageId)") - } - } - } - } - } - .padding(10) - .overlay( - RoundedRectangle(cornerRadius: 18) - .stroke(Color.gray, lineWidth: 1) - ) + if currentUser && message.canRetry || (message.receivedACK && !message.realACK) { + RetryButton(message: message) } } + + TapbackResponses(message: message) { + appState.unreadDirectMessages = user.unreadMessages + UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages + } + HStack { let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) if currentUser && message.receivedACK { @@ -203,10 +87,6 @@ struct UserMessageList: View { .padding(.bottom) .id(user.messageList.firstIndex(of: message)) - if currentUser && (message.ackError == 9 || message.ackError == 5 || message.ackError == 3) || (message.receivedACK && !message.realACK) { - RetryButton(message: message) - } - if !currentUser { Spacer(minLength: 50) } @@ -214,20 +94,6 @@ struct UserMessageList: View { .padding([.leading, .trailing]) .frame(maxWidth: .infinity) .id(message.messageId) - .alert(isPresented: $showDeleteMessageAlert) { - Alert(title: Text("Are you sure you want to delete this message?"), message: Text("This action is permanent."), primaryButton: .destructive(Text("Delete")) { - if deleteMessageId > 0 { - let message = user.messageList.first(where: { $0.messageId == deleteMessageId }) - context.delete(message!) - do { - try context.save() - deleteMessageId = 0 - } catch { - print("Failed to delete message \(deleteMessageId)") - } - } - }, secondaryButton: .cancel()) - } .onAppear { if !message.read { message.read = true @@ -264,7 +130,7 @@ struct UserMessageList: View { } TextMessageField( - destination: .user(user.num), + destination: .user(user), replyMessageId: $replyMessageId, isFocused: $messageFieldFocused ) { diff --git a/Meshtastic/Views/Nodes/NodeMap.swift b/Meshtastic/Views/Nodes/NodeMap.swift index d5ce3676..cd79ef0d 100644 --- a/Meshtastic/Views/Nodes/NodeMap.swift +++ b/Meshtastic/Views/Nodes/NodeMap.swift @@ -13,7 +13,6 @@ import CoreData struct NodeMap: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager - @ObservedObject var tileManager = OfflineTileManager.shared @StateObject var appState = AppState.shared @State var selectedMapLayer: MapLayer = UserDefaults.mapLayer @State var enableMapRecentering: Bool = UserDefaults.enableMapRecentering @@ -71,6 +70,9 @@ struct NodeMap: View { .padding(.top, 16) } Spacer() + TileDownloadStatus() + .padding(.trailing, 16) + .padding(.bottom, 20) } } .ignoresSafeArea(.all, edges: [.top, .leading, .trailing])