// //  UserMessageList.swift //  MeshtasticApple // //  Created by Garth Vander Houwen on 12/24/21. // import SwiftUI import CoreData import OSLog import MeshtasticProtobufs // Added to ensure RoutingError is accessible if needed struct UserMessageList: View { @EnvironmentObject var appState: AppState @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.scenePhase) var scenePhase @Environment(\.managedObjectContext) var context @FocusState var messageFieldFocused: Bool @ObservedObject var user: UserEntity @State private var replyMessageId: Int64 = 0 @State private var messageToHighlight: Int64 = 0 @State private var redrawTapbacksTrigger = UUID() @AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1 @FetchRequest private var allPrivateMessages: FetchedResults init(user: UserEntity) { self.user = user // Configure fetch request here let request: NSFetchRequest = user.messageFetchRequest _allPrivateMessages = FetchRequest(fetchRequest: request) } func handleInteractionComplete() { markMessagesAsRead() redrawTapbacksTrigger = UUID() } func markMessagesAsRead() { do { for unreadMessage in allPrivateMessages.filter({ !$0.read }) { unreadMessage.read = true } try context.save() Logger.data.info("📖 [App] All unread direct messages marked as read for user \(user.num, privacy: .public).") 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 appState.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(messages, id: \.messageId) { message in let previousMessage: MessageEntity? = previousByID[message.messageId] ?? nil UserMessageRow( message: message, allMessages: messages, previousMessage: previousMessage, preferredPeripheralNum: preferredPeripheralNum, user: user, replyMessageId: $replyMessageId, messageFieldFocused: $messageFieldFocused, messageToHighlight: $messageToHighlight, scrollView: scrollView, onInteractionComplete: handleInteractionComplete ) .onAppear { // Only mark as read if the app is in the foreground if !message.read && UIApplication.shared.applicationState == .active { message.read = true LocalNotificationManager().cancelNotificationForMessageId(message.messageId) // Race condition, sometimes the app doesn't update unread count if we run this too early // So, run it in the main queue after everything saves and stabilizes DispatchQueue.main.async { markMessagesAsRead() scrollView.scrollTo("bottomAnchor", anchor: .bottom) } } } } // Invisible spacer to detect reaching bottom Color.clear .frame(height: 1) .id("bottomAnchor") } } .defaultScrollAnchor(.bottom) .defaultScrollAnchorTopAlignment() .defaultScrollAnchorBottomSizeChanges() .scrollDismissesKeyboard(.immediately) .onChange(of: messageFieldFocused) { if messageFieldFocused { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { scrollView.scrollTo("bottomAnchor", anchor: .bottom) } } } } TextMessageField( destination: .user(user), replyMessageId: $replyMessageId, isFocused: $messageFieldFocused ) } .navigationBarTitleDisplayMode(.inline) .toolbar { if !user.keyMatch { ToolbarItem(placement: .bottomBar) { VStack { HStack { Image(systemName: "key.slash.fill") .symbolRenderingMode(.multicolor) .foregroundStyle(.red) .font(.caption2) Text("There is an issue with this contact's public key.") .foregroundStyle(.secondary) .font(.caption2) } Link(destination: URL(string: "meshtastic:///nodes?nodenum=\(user.num)")!) { Text("Details...") .font(.caption2) .offset(y: -15) } } .offset(y: -15) } } ToolbarItem(placement: .principal) { HStack { CircleText(text: user.shortName ?? "?", color: Color(UIColor(hex: UInt32(user.num))), circleSize: 44) Text(user.longName ?? "Unknown").font(.headline) } } ToolbarItem(placement: .navigationBarTrailing) { ZStack { ConnectedDevice( deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") } } } } }