diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 25d25afc..62cf1085 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -62,6 +62,7 @@ BCB613832C672A2600485544 /* MessageChannelIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613822C672A2600485544 /* MessageChannelIntent.swift */; }; BCB613852C68703800485544 /* NodePositionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613842C68703800485544 /* NodePositionIntent.swift */; }; BCB613872C69A0FB00485544 /* AppIntentErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613862C69A0FB00485544 /* AppIntentErrors.swift */; }; + BCDDFA9A2DBB180D0065189C /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */; }; BCE2D3C32C7ADF42008E6199 /* ShutDownNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE2D3C22C7ADF42008E6199 /* ShutDownNodeIntent.swift */; }; BCE2D3C52C7AE369008E6199 /* RestartNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE2D3C42C7AE369008E6199 /* RestartNodeIntent.swift */; }; BCE2D3C72C7B0D0A008E6199 /* ShortcutsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE2D3C62C7B0D0A008E6199 /* ShortcutsProvider.swift */; }; @@ -324,6 +325,7 @@ BCB613822C672A2600485544 /* MessageChannelIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageChannelIntent.swift; sourceTree = ""; }; BCB613842C68703800485544 /* NodePositionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodePositionIntent.swift; sourceTree = ""; }; BCB613862C69A0FB00485544 /* AppIntentErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentErrors.swift; sourceTree = ""; }; + BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = ""; }; BCE2D3C22C7ADF42008E6199 /* ShutDownNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShutDownNodeIntent.swift; sourceTree = ""; }; BCE2D3C42C7AE369008E6199 /* RestartNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartNodeIntent.swift; sourceTree = ""; }; BCE2D3C62C7B0D0A008E6199 /* ShortcutsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsProvider.swift; sourceTree = ""; }; @@ -1107,6 +1109,7 @@ DDDB26412AABF655003AFCB7 /* NodeListItem.swift */, DDDCD56F2BB26F5C00BE6B60 /* NodeListFilter.swift */, 251926882C3BAF2E00249DF5 /* Actions */, + BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */, ); path = Helpers; sourceTree = ""; @@ -1545,6 +1548,7 @@ DDA9515E2BC6F56F00CEA535 /* IndoorAirQuality.swift in Sources */, DDDB444E29F8AB0E00EE2349 /* Int.swift in Sources */, DD3CC6BC28E366DF00FA9159 /* Meshtastic.xcdatamodeld in Sources */, + BCDDFA9A2DBB180D0065189C /* ScrollToBottomButton.swift in Sources */, DDC4C9FF2A8D982900CE201C /* DetectionSensorConfig.swift in Sources */, 2344A2AF2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift in Sources */, 2344A2B02D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift in Sources */, diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index c2361ccb..0696e9d8 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -22,6 +22,11 @@ struct ChannelMessageList: View { @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 gotFirstUnreadMessage: Bool = false var body: some View { VStack { @@ -103,6 +108,7 @@ struct ChannelMessageList: View { } } .padding(.bottom) + .id(channel.allPrivateMessages.firstIndex(of: message)) if !currentUser { Spacer(minLength: 50) @@ -112,49 +118,97 @@ struct ChannelMessageList: View { .frame(maxWidth: .infinity) .id(message.messageId) .onAppear { - if !message.read { - message.read = true - do { - for unreadMessage in channel.allPrivateMessages.filter({ !$0.read }) { - unreadMessage.read = true + if gotFirstUnreadMessage{ + if !message.read { + message.read = true + do { + for unreadMessage in channel.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)") } - 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 == channel.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 { - withAnimation { - scrollView.scrollTo(channel.allPrivateMessages.last?.messageId ?? 0, anchor: .bottom) + // Find first unread message + if let firstUnreadMessageId = channel.allPrivateMessages.first(where: { !$0.read })?.messageId { + withAnimation { + scrollView.scrollTo(firstUnreadMessageId, anchor: .top) + showScrollToBottomButton = true + } + } else { + // If no unread messages, scroll to bottom + withAnimation { + scrollView.scrollTo(channel.allPrivateMessages.last?.messageId ?? 0, anchor: .bottom) + hasReachedBottom = true + } } + gotFirstUnreadMessage = true } .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in withAnimation { scrollView.scrollTo(channel.allPrivateMessages.last?.messageId ?? 0, anchor: .bottom) + hasReachedBottom = true + showScrollToBottomButton = false } } .onChange(of: channel.allPrivateMessages) { - withAnimation { - scrollView.scrollTo(channel.allPrivateMessages.last?.messageId ?? 0, anchor: .bottom) + if hasReachedBottom { + withAnimation { + scrollView.scrollTo(channel.allPrivateMessages.last?.messageId ?? 0, 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) - } + TextMessageField( + destination: .channel(channel), + replyMessageId: $replyMessageId, + isFocused: $messageFieldFocused + ) { + context.refresh(channel, mergeChanges: true) } } .navigationBarTitleDisplayMode(.inline) diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index dea4586f..6f995756 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -20,115 +20,172 @@ struct UserMessageList: View { // 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 gotFirstUnreadMessage: Bool = false var body: some View { VStack { ScrollViewReader { scrollView in - ScrollView { - LazyVStack { - ForEach( user.messageList ) { (message: MessageEntity) in - if user.num != bleManager.connectedPeripheral?.num ?? -1 { - let currentUser: Bool = (Int64(UserDefaults.preferredPeripheralNum) == message.fromUser?.num ?? -1 ? true : false) + ZStack(alignment: .bottomTrailing) { + ScrollView { + LazyVStack { + ForEach( user.messageList ) { (message: MessageEntity) in + if user.num != bleManager.connectedPeripheral?.num ?? -1 { + let currentUser: Bool = (Int64(UserDefaults.preferredPeripheralNum) == message.fromUser?.num ?? -1 ? true : false) - if message.replyID > 0 { - let messageReply = user.messageList.first(where: { $0.messageId == message.replyID }) - HStack { - 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) + if message.replyID > 0 { + let messageReply = user.messageList.first(where: { $0.messageId == message.replyID }) + HStack { + 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: .top) { - if currentUser { Spacer(minLength: 50) } - VStack(alignment: currentUser ? .trailing : .leading) { - HStack { - MessageText( - message: message, - tapBackDestination: .user(user), - isCurrentUser: currentUser - ) { - self.replyMessageId = message.messageId - self.messageFieldFocused = true - } - - if currentUser && message.canRetry || (message.receivedACK && !message.realACK) { - RetryButton(message: message, destination: .user(user)) - } - } - - TapbackResponses(message: message) { - appState.unreadDirectMessages = user.unreadMessages - } - - HStack { - let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) - if currentUser && message.receivedACK { - // Ack Received - if message.realACK { - Text("\(ackErrorVal?.display ?? "Empty Ack Error")") - .font(.caption2) - .foregroundStyle(ackErrorVal?.color ?? Color.secondary) - } else { - Text("Acknowledged by another node").font(.caption2).foregroundColor(.orange) + HStack(alignment: .top) { + if currentUser { Spacer(minLength: 50) } + VStack(alignment: currentUser ? .trailing : .leading) { + HStack { + MessageText( + message: message, + tapBackDestination: .user(user), + isCurrentUser: currentUser + ) { + self.replyMessageId = message.messageId + self.messageFieldFocused = true + } + + if currentUser && message.canRetry || (message.receivedACK && !message.realACK) { + RetryButton(message: message, destination: .user(user)) + } + } + + TapbackResponses(message: message) { + appState.unreadDirectMessages = user.unreadMessages + } + + HStack { + let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) + if currentUser && message.receivedACK { + // Ack Received + if message.realACK { + Text("\(ackErrorVal?.display ?? "Empty Ack Error")") + .font(.caption2) + .foregroundStyle(ackErrorVal?.color ?? Color.secondary) + } else { + Text("Acknowledged by another node").font(.caption2).foregroundColor(.orange) + } + } else if currentUser && message.ackError == 0 { + // Empty Error + Text("Waiting to be acknowledged. . .").font(.caption2).foregroundColor(.yellow) + } else if currentUser && message.ackError > 0 { + 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(.yellow) - } else if currentUser && message.ackError > 0 { - Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true) - .foregroundStyle(ackErrorVal?.color ?? Color.red) - .font(.caption2) } } - } - .padding(.bottom) - .id(user.messageList.firstIndex(of: message)) + .padding(.bottom) + .id(user.messageList.firstIndex(of: message)) - if !currentUser { - Spacer(minLength: 50) + if !currentUser { + Spacer(minLength: 50) + } } - } - .padding([.leading, .trailing]) - .frame(maxWidth: .infinity) - .id(message.messageId) - .onAppear { - if !message.read { - message.read = true - do { - try context.save() - Logger.data.info("📖 [App] Read message \(message.messageId, privacy: .public) ") - appState.unreadDirectMessages = user.unreadMessages - - } catch { - Logger.data.error("Failed to read message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)") + .padding([.leading, .trailing]) + .frame(maxWidth: .infinity) + .id(message.messageId) + .onAppear { + if gotFirstUnreadMessage { + if !message.read { + message.read = true + do { + for unreadMessage in user.messageList.filter({ !$0.read }) { + unreadMessage.read = true + } + try context.save() + Logger.data.info("📖 [App] Read message \(message.messageId, privacy: .public) ") + appState.unreadDirectMessages = user.unreadMessages + } 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 == user.messageList.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 { - withAnimation { - scrollView.scrollTo(user.messageList.last?.messageId ?? 0, anchor: .bottom) + .scrollDismissesKeyboard(.interactively) + .onFirstAppear { + // Find first unread message + if let firstUnreadMessageId = user.messageList.first(where: { !$0.read })?.messageId { + withAnimation { + scrollView.scrollTo(firstUnreadMessageId, anchor: .top) + showScrollToBottomButton = true + } + } else { + // If no unread messages, scroll to bottom + withAnimation { + scrollView.scrollTo(user.messageList.last?.messageId ?? 0, anchor: .bottom) + hasReachedBottom = true + } + } + gotFirstUnreadMessage = true } - } - .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in - withAnimation { - scrollView.scrollTo(user.messageList.last?.messageId ?? 0, anchor: .bottom) + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in + withAnimation { + scrollView.scrollTo(user.messageList.last?.messageId ?? 0, anchor: .bottom) + hasReachedBottom = true + showScrollToBottomButton = false + } } - } - .onChange(of: user.messageList) { - withAnimation { - scrollView.scrollTo(user.messageList.last?.messageId ?? 0, anchor: .bottom) + .onChange(of: user.messageList) { + if hasReachedBottom { + withAnimation { + scrollView.scrollTo(user.messageList.last?.messageId ?? 0, 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) } } } diff --git a/Meshtastic/Views/Nodes/Helpers/ScrollToBottomButton.swift b/Meshtastic/Views/Nodes/Helpers/ScrollToBottomButton.swift new file mode 100644 index 00000000..da10d18a --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/ScrollToBottomButton.swift @@ -0,0 +1,30 @@ +// +// ScrollToBottomButtonView.swift +// Meshtastic +// +// Created by Benjamin Faershtein on 4/2/25. +// + +import SwiftUI + +struct ScrollToBottomButtonView: View { + var body: some View { + HStack(spacing: 4) { + Text("Jump to present") + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .cornerRadius(12) + Image(systemName: "arrow.down") + .font(.title2) + .symbolRenderingMode(.hierarchical) + + } + .foregroundColor(.accentColor) + .shadow(radius: 2) + } +} + +#Preview { + ScrollToBottomButtonView() +}