Merge pull request #500 from Austinpayne/improvement/message-refactors

Improvement: various message list refactors
This commit is contained in:
Garth Vander Houwen 2024-02-17 14:47:28 -08:00 committed by GitHub
commit d4e6e0b580
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 364 additions and 343 deletions

View file

@ -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 = "<group>"; };
B3E905B02B71F7F300654D07 /* TextMessageField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMessageField.swift; sourceTree = "<group>"; };
C9697F9C279336B700250207 /* LocalMBTileOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMBTileOverlay.swift; sourceTree = "<group>"; };
D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageContextMenuItems.swift; sourceTree = "<group>"; };
D93068D42B812B700066FBC8 /* MessageDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDestination.swift; sourceTree = "<group>"; };
D93068D62B8146690066FBC8 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = "<group>"; };
D93068D82B81509C0066FBC8 /* TapbackResponses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapbackResponses.swift; sourceTree = "<group>"; };
D9BC22DA2B7DE8E2006A37D5 /* TileDownloadStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileDownloadStatus.swift; sourceTree = "<group>"; };
D9C9839C2B79CFD700BDBE6A /* TextMessageSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMessageSize.swift; sourceTree = "<group>"; };
D9C9839F2B79D0E800BDBE6A /* AlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertButton.swift; sourceTree = "<group>"; };
D9C983A12B79D1A600BDBE6A /* RequestPositionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestPositionButton.swift; sourceTree = "<group>"; };
@ -494,6 +504,7 @@
DD964FC32974767D007C176F /* MapViewFitExtension.swift */,
DD2AD8A7296D2DF9001FF0E7 /* MapViewSwiftUI.swift */,
DDDB443529F6287000EE2349 /* MapButtons.swift */,
D9BC22DA2B7DE8E2006A37D5 /* TileDownloadStatus.swift */,
);
path = Custom;
sourceTree = "<group>";
@ -690,6 +701,7 @@
DD1925B828CDA93900720036 /* SerialConfigEnums.swift */,
DD994B68295F88B60013760A /* IntervalEnums.swift */,
DD5E5239298EFA5300D21B61 /* TelemetryWeather.swift */,
D93068D42B812B700066FBC8 /* MessageDestination.swift */,
);
path = Enums;
sourceTree = "<group>";
@ -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 = "<group>";
@ -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 */,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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