diff --git a/Meshtastic/Views/Messages/ChannelList.swift b/Meshtastic/Views/Messages/ChannelList.swift index b2c25c3b..fc1e8f96 100644 --- a/Meshtastic/Views/Messages/ChannelList.swift +++ b/Meshtastic/Views/Messages/ChannelList.swift @@ -170,7 +170,6 @@ struct ChannelList: View { } .olderThanOS26 { $0.padding([.top, .bottom]) } .listStyle(.plain) - .navigationTitle("Channels") } } .sheet(isPresented: $showingHelp) { @@ -196,5 +195,6 @@ struct ChannelList: View { .padding(5) } .padding(.bottom, 5) + .navigationTitle("Channels") } } diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index 170a7370..8d5bd14d 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -1,6 +1,6 @@ // -// UserMessageList.swift -// MeshtasticApple +// ChannelMessageList.swift +// Meshtastic // // Created by Garth Vander Houwen on 12/24/21. // @@ -14,17 +14,12 @@ struct ChannelMessageList: View { @EnvironmentObject var appState: AppState @Environment(\.managedObjectContext) var context @EnvironmentObject var accessoryManager: AccessoryManager - // Keyboard State @FocusState var messageFieldFocused: Bool @ObservedObject var myInfo: MyInfoEntity @ObservedObject var channel: ChannelEntity @State private var replyMessageId: Int64 = 0 @AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1 - // Scroll state - @State private var showScrollToBottomButton = false - @State private var hasReachedBottom = false @State private var messageToHighlight: Int64 = 0 - @FetchRequest private var allPrivateMessages: FetchedResults init(myInfo: MyInfoEntity, channel: ChannelEntity) { @@ -42,230 +37,176 @@ struct ChannelMessageList: View { ) _allPrivateMessages = FetchRequest(fetchRequest: request) } - - var body: some View { - VStack { - ScrollViewReader { scrollView in - ZStack(alignment: .bottomTrailing) { - ScrollView { - LazyVStack { - ForEach(allPrivateMessages) { message in - // Get the previous message, if it exists - let thisMessageIndex = allPrivateMessages.firstIndex(of: message) ?? 0 - let previousMessage = thisMessageIndex > 0 ? allPrivateMessages[thisMessageIndex - 1] : nil - let currentUser: Bool = (Int64(preferredPeripheralNum) == message.fromUser?.num ? true : false) - if message.displayTimestamp(aboveMessage: previousMessage) { - Text(message.timestamp.formatted(date: .abbreviated, time: .shortened)) - .font(.caption) - .foregroundColor(.gray) - } - if message.replyID > 0 { - let messageReply = allPrivateMessages.first(where: { $0.messageId == message.replyID }) - HStack { - Button { - if let messageNum = messageReply?.messageId { - withAnimation(.easeInOut(duration: 0.5)) { - messageToHighlight = messageNum - } - scrollView.scrollTo(messageNum, anchor: .center) - // Reset highlight after delay - Task { - try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second - withAnimation(.easeInOut(duration: 0.5)) { - messageToHighlight = -1 - } - } - } - } label: { - Text(messageReply?.messagePayload ?? "EMPTY MESSAGE").foregroundColor(.accentColor).font(.caption2) - .padding(10) - .overlay( - RoundedRectangle(cornerRadius: 18) - .stroke(Color.blue, lineWidth: 0.5) - ) - Image(systemName: "arrowshape.turn.up.left.fill") - .symbolRenderingMode(.hierarchical) - .imageScale(.large).foregroundColor(.accentColor) - .padding(.trailing) - } - } - } - HStack(alignment: .bottom) { - if currentUser { Spacer(minLength: 50) } - if !currentUser { - CircleText(text: message.fromUser?.shortName ?? "?", color: Color(UIColor(hex: UInt32(message.fromUser?.num ?? 0))), circleSize: 44, node: getNodeInfo(id: Int64(message.fromUser?.num ?? 0), context: context)) - .padding(.all, 5) - .offset(y: -7) - } - - VStack(alignment: currentUser ? .trailing : .leading) { - let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue) - - if !currentUser && message.fromUser != nil { - Text("\(message.fromUser?.longName ?? "Unknown".localized ) (\(message.fromUser?.userId ?? "?"))") - .font(.caption) - .foregroundColor(.gray) - .offset(y: 8) - } - - HStack { - MessageText( - message: message, - tapBackDestination: .channel(channel), - isCurrentUser: currentUser - ) { - self.replyMessageId = message.messageId - self.messageFieldFocused = true - } - - if currentUser && message.canRetry { - RetryButton(message: message, destination: .channel(channel)) - } - } - - TapbackResponses(message: message) { - appState.unreadChannelMessages = myInfo.unreadMessages - context.refresh(myInfo, mergeChanges: true) - } - - HStack { - let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) - if currentUser && message.receivedACK { - Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true) - .foregroundStyle(ackErrorVal?.color ?? Color.red) - .font(.caption2) - } else if currentUser && message.ackError == 0 { - // Empty Error - Text("Waiting to be acknowledged. . .").font( - .caption2) - .foregroundColor(.orange) - } else if currentUser && !isDetectionSensorMessage { - Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true) - .foregroundStyle(ackErrorVal?.color ?? Color.red) - .font(.caption2) - } - } - } - .padding(.bottom) - .id(allPrivateMessages.firstIndex(of: message)) - - if !currentUser { - Spacer(minLength: 50) - } - } - .padding([.leading, .trailing]) - .frame(maxWidth: .infinity) - .id(message.messageId) - .onAppear { - if !message.read { - message.read = true - do { - for unreadMessage in allPrivateMessages.filter({ !$0.read }) { - unreadMessage.read = true - } - try context.save() - Logger.data.info("📖 [App] Read message \(message.messageId, privacy: .public) ") - appState.unreadChannelMessages = myInfo.unreadMessages - context.refresh(myInfo, mergeChanges: true) - } catch { - Logger.data.error("Failed to read message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)") - } - } - // Check if we've reached the bottom message - if message.messageId == allPrivateMessages.last?.messageId { - hasReachedBottom = true - showScrollToBottomButton = false - } } - } - // Invisible spacer to detect reaching bottom - Color.clear - .frame(height: 1) - .id("bottomAnchor") - .onAppear { - hasReachedBottom = true - showScrollToBottomButton = false - } - } - } - .scrollDismissesKeyboard(.interactively) - .onFirstAppear { - DispatchQueue.main.async { - if channel.unreadMessages == 0 { - withAnimation { - scrollView.scrollTo("bottomAnchor", anchor: .bottom) - hasReachedBottom = true - } - } else { - if let firstUnreadMessageId = allPrivateMessages.first(where: { !$0.read })?.messageId { - withAnimation { - scrollView.scrollTo(firstUnreadMessageId, anchor: .top) - showScrollToBottomButton = true - } - } - } - } - } - .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in - withAnimation { - scrollView.scrollTo("bottomAnchor", anchor: .bottom) - hasReachedBottom = true - showScrollToBottomButton = false - } - } - .onChange(of: allPrivateMessages.count) { - if hasReachedBottom { - withAnimation { - scrollView.scrollTo("bottomAnchor", anchor: .bottom) - } - } else { - showScrollToBottomButton = true - } - } - // Scroll to bottom button - if showScrollToBottomButton { - Button { - withAnimation { - scrollView.scrollTo("bottomAnchor", anchor: .bottom) - hasReachedBottom = true - showScrollToBottomButton = false - } - } label: { - ScrollToBottomButtonView() - } - .padding(.bottom, 8) - .padding(.trailing, 16) - .transition(.opacity) - } - } - } - - TextMessageField( - destination: .channel(channel), - replyMessageId: $replyMessageId, - isFocused: $messageFieldFocused - ) { - context.refresh(channel, mergeChanges: true) + + func markMessagesAsRead() { + do { + for unreadMessage in allPrivateMessages.filter({ !$0.read }) { + unreadMessage.read = true } + try context.save() + Logger.data.info("📖 [App] All unread messages marked as read.") + appState.unreadChannelMessages = myInfo.unreadMessages + context.refresh(myInfo, mergeChanges: true) + } catch { + Logger.data.error("Failed to read messages: \(error.localizedDescription, privacy: .public)") } - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .principal) { - HStack { - CircleText(text: String(channel.index), color: .accentColor, circleSize: 44).fixedSize() - Text(String(channel.name ?? "Unknown".localized).camelCaseToWords()).font(.headline) + } + + var body: some View { + NavigationStack { + ScrollViewReader { scrollView in + ScrollView { + LazyVStack { + ForEach(allPrivateMessages) { message in + let thisMessageIndex = allPrivateMessages.firstIndex(of: message) ?? 0 + let previousMessage = thisMessageIndex > 0 ? allPrivateMessages[thisMessageIndex - 1] : nil + let currentUser: Bool = (Int64(preferredPeripheralNum) == message.fromUser?.num ? true : false) + if message.displayTimestamp(aboveMessage: previousMessage) { + Text(message.timestamp.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundColor(.gray) + } + if message.replyID > 0 { + let messageReply = allPrivateMessages.first(where: { $0.messageId == message.replyID }) + HStack { + Button { + if let messageNum = messageReply?.messageId { + withAnimation(.easeInOut(duration: 0.5)) { + messageToHighlight = messageNum + } + scrollView.scrollTo(messageNum, anchor: .center) + Task { + try? await Task.sleep(nanoseconds: 1_000_000_000) + withAnimation(.easeInOut(duration: 0.5)) { + messageToHighlight = -1 + } + } + } + } label: { + Text(messageReply?.messagePayload ?? "EMPTY MESSAGE").foregroundColor(.accentColor).font(.caption2) + .padding(10) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(Color.blue, lineWidth: 0.5) + ) + Image(systemName: "arrowshape.turn.up.left.fill") + .symbolRenderingMode(.hierarchical) + .imageScale(.large).foregroundColor(.accentColor) + .padding(.trailing) + } + } + } + HStack(alignment: .bottom) { + if currentUser { Spacer(minLength: 50) } + if !currentUser { + CircleText(text: message.fromUser?.shortName ?? "?", color: Color(UIColor(hex: UInt32(message.fromUser?.num ?? 0))), circleSize: 44, node: getNodeInfo(id: Int64(message.fromUser?.num ?? 0), context: context)) + .padding(.all, 5) + .offset(y: -7) + } + + VStack(alignment: currentUser ? .trailing : .leading) { + let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue) + + if !currentUser && message.fromUser != nil { + Text("\(message.fromUser?.longName ?? "Unknown".localized ) (\(message.fromUser?.userId ?? "?"))") + .font(.caption) + .foregroundColor(.gray) + .offset(y: 8) + } + + HStack { + MessageText( + message: message, + tapBackDestination: .channel(channel), + isCurrentUser: currentUser + ) { + self.replyMessageId = message.messageId + self.messageFieldFocused = true + } + + if currentUser && message.canRetry { + RetryButton(message: message, destination: .channel(channel)) + } + } + + TapbackResponses(message: message) { + appState.unreadChannelMessages = myInfo.unreadMessages + context.refresh(myInfo, mergeChanges: true) + } + + HStack { + let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) + if currentUser && message.receivedACK { + Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true) + .foregroundStyle(ackErrorVal?.color ?? Color.red) + .font(.caption2) + } else if currentUser && message.ackError == 0 { + Text("Waiting to be acknowledged. . .").font(.caption2).foregroundColor(.orange) + } else if currentUser && !isDetectionSensorMessage { + Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true) + .foregroundStyle(ackErrorVal?.color ?? Color.red) + .font(.caption2) + } + } + } + .padding(.bottom) + .id(allPrivateMessages.firstIndex(of: message)) + + if !currentUser { + Spacer(minLength: 50) + } + } + .padding([.leading, .trailing]) + .frame(maxWidth: .infinity) + .id(message.messageId) + .onAppear { + markMessagesAsRead() + } + } + 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) + } + } + } + .safeAreaInset(edge: .bottom) { + TextMessageField( + destination: .channel(channel), + replyMessageId: $replyMessageId, + isFocused: $messageFieldFocused + ) + .background(.bar) } } - ToolbarItem(placement: .navigationBarTrailing) { - ZStack { - ConnectedDevice( - deviceConnected: accessoryManager.isConnected, - name: accessoryManager.activeConnection?.device.shortName ?? "?", - // mqttProxyConnected defaults to false, so if it's not enabled it will still be false - mqttProxyConnected: accessoryManager.mqttProxyConnected && (channel.uplinkEnabled || channel.downlinkEnabled), - mqttUplinkEnabled: channel.uplinkEnabled, - mqttDownlinkEnabled: channel.downlinkEnabled, - mqttTopic: accessoryManager.mqttManager.topic - ) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + HStack { + CircleText(text: String(channel.index), color: .accentColor, circleSize: 44).fixedSize() + Text(String(channel.name ?? "Unknown").camelCaseToWords()).font(.headline) + } + } + ToolbarItem(placement: .navigationBarTrailing) { + ZStack { + ConnectedDevice( + deviceConnected: accessoryManager.isConnected, + name: accessoryManager.activeConnection?.device.shortName ?? "?", + mqttProxyConnected: accessoryManager.mqttProxyConnected && (channel.uplinkEnabled || channel.downlinkEnabled), + mqttUplinkEnabled: channel.uplinkEnabled, + mqttDownlinkEnabled: channel.downlinkEnabled, + mqttTopic: accessoryManager.mqttManager.topic + ) + } } } } diff --git a/Meshtastic/Views/Messages/Messages.swift b/Meshtastic/Views/Messages/Messages.swift index 1f3db5bb..0fd4e0bf 100644 --- a/Meshtastic/Views/Messages/Messages.swift +++ b/Meshtastic/Views/Messages/Messages.swift @@ -71,14 +71,18 @@ struct Messages: View { switch router.navigationState.messages { case .channels(let channelId, let messageId): ChannelList(node: $node, channelSelection: $channelSelection) + // Removed navigationTitle and navigationBarTitleDisplayMode here. + // ChannelList.swift now handles this within its own NavigationStack. case .directMessages(let userNum, let messageId): UserList(node: $node, userSelection: $userSelection) + // Removed navigationTitle here. UserList will handle this. case nil: Text("Select a conversation type") } } detail: { if let myInfo = node?.myInfo, let channelSelection { ChannelMessageList(myInfo: myInfo, channel: channelSelection) + // The toolbar is now defined inside ChannelMessageList.swift } else if let userSelection { UserMessageList(user: userSelection) } else if case .channels = router.navigationState.messages { diff --git a/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift b/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift index ef0fdd73..c7371851 100644 --- a/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift +++ b/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift @@ -9,7 +9,6 @@ struct TextMessageField: View { let destination: MessageDestination @Binding var replyMessageId: Int64 @FocusState.Binding var isFocused: Bool - let onSubmit: () -> Void @State private var typingMessage: String = "" @State private var totalBytes = 0 @@ -122,7 +121,6 @@ struct TextMessageField: View { typingMessage = "" isFocused = false replyMessageId = 0 - onSubmit() if sendPositionWithMessage { try await accessoryManager.sendPosition( diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index 0eafa195..81f248f3 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -14,14 +14,9 @@ struct UserMessageList: View { @EnvironmentObject var appState: AppState @EnvironmentObject var accessoryManager: AccessoryManager @Environment(\.managedObjectContext) var context - // Keyboard State @FocusState var messageFieldFocused: Bool - // View State Items @ObservedObject var user: UserEntity @State private var replyMessageId: Int64 = 0 - // Scroll state - @State private var showScrollToBottomButton = false - @State private var hasReachedBottom = false @State private var messageToHighlight: Int64 = 0 var body: some View { @@ -139,11 +134,6 @@ struct UserMessageList: View { Logger.data.error("Failed to read message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)") } } - // Check if we've reached the bottom message - if message.messageId == user.messageList.last?.messageId { - hasReachedBottom = true - showScrollToBottomButton = false - } } } } @@ -151,71 +141,28 @@ struct UserMessageList: View { Color.clear .frame(height: 1) .id("bottomAnchor") - .onAppear { - hasReachedBottom = true - showScrollToBottomButton = false - } } } - .scrollDismissesKeyboard(.interactively) - .onFirstAppear { - if user.unreadMessages == 0 { - withAnimation { - scrollView.scrollTo("bottomAnchor", anchor: .bottom) - hasReachedBottom = true - } - } else { - if let firstUnreadMessageId = user.messageList.first(where: { !$0.read })?.messageId { - withAnimation { - scrollView.scrollTo(firstUnreadMessageId, anchor: .top) - showScrollToBottomButton = true - } - } - } - } - .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in - withAnimation { - scrollView.scrollTo("bottomAnchor", anchor: .bottom) - hasReachedBottom = true - showScrollToBottomButton = false - } - } - .onChange(of: user.messageList) { - if hasReachedBottom { - withAnimation { + .defaultScrollAnchor(.bottom) + .defaultScrollAnchorTopAlignment() + .defaultScrollAnchorBottomSizeChanges() + .scrollDismissesKeyboard(.immediately) + .onChange(of: messageFieldFocused) { + if messageFieldFocused { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { scrollView.scrollTo("bottomAnchor", anchor: .bottom) } - } else { - showScrollToBottomButton = true } } - // Scroll to bottom button - if showScrollToBottomButton { - Button { - withAnimation { - scrollView.scrollTo("bottomAnchor", anchor: .bottom) - hasReachedBottom = true - showScrollToBottomButton = false - } - } label: { - ScrollToBottomButtonView() - } - .padding(.bottom, 8) - .padding(.trailing, 16) - .transition(.opacity) - } } } - TextMessageField( destination: .user(user), replyMessageId: $replyMessageId, isFocused: $messageFieldFocused - ) { - context.refresh(user, mergeChanges: true) - } + ) } - .navigationBarTitleDisplayMode(.large) + .navigationBarTitleDisplayMode(.inline) .toolbar { if !user.keyMatch { ToolbarItem(placement: .bottomBar) {