ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification

This commit is contained in:
Mike Robbins 2025-10-14 16:58:55 -04:00
parent bce26c46de
commit ee1a7c4415
2 changed files with 69 additions and 18 deletions

View file

@ -23,7 +23,8 @@ struct ChannelMessageList: View {
@AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1
@State private var messageToHighlight: Int64 = 0
@FetchRequest private var allPrivateMessages: FetchedResults<MessageEntity>
@State private var scrollToBottomWorkItem: DispatchWorkItem?
init(myInfo: MyInfoEntity, channel: ChannelEntity) {
self.myInfo = myInfo
self.channel = channel
@ -50,15 +51,30 @@ struct ChannelMessageList: View {
for unreadMessage in allPrivateMessages.filter({ !$0.read }) {
unreadMessage.read = true
}
try context.save()
Logger.data.info("📖 [App] All unread messages marked as read.")
if context.hasChanges {
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)")
}
}
func debouncedScrollToBottom(scrollView: ScrollViewProxy, lastMessageId: Int64?, delay: TimeInterval = 0.1) {
scrollToBottomWorkItem?.cancel()
let scrollTarget: AnyHashable = lastMessageId != nil ? lastMessageId : "bottomAnchor"
let work = DispatchWorkItem {
scrollView.scrollTo(scrollTarget, anchor: .bottom)
}
scrollToBottomWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
}
var body: some View {
// Cast allPrivateMessages to an array for easier indexing and ForEach.
let messages: [MessageEntity] = Array(allPrivateMessages)
@ -71,6 +87,8 @@ struct ChannelMessageList: View {
return dict
}()
let lastMessageId: Int64? = messages.last?.messageId
ScrollViewReader { scrollView in
ScrollView {
LazyVStack {
@ -98,11 +116,10 @@ struct ChannelMessageList: View {
// So, run it in the main queue after everything saves and stabilizes
DispatchQueue.main.async {
markMessagesAsRead()
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
}
debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.1)
}
}
}
Color.clear
.frame(height: 1)
@ -113,12 +130,21 @@ struct ChannelMessageList: View {
.defaultScrollAnchorTopAlignment()
.defaultScrollAnchorBottomSizeChanges()
.scrollDismissesKeyboard(.immediately)
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
// Keyboard is about to appear: keyboard show animation hasn't quite started yet.
// Schedule an immediate scroll to the bottom message by its messageId, in order to force LazyVStack to render that cell if it isn't rendered already
debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.0)
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in
// Keyboard is fully visible.
// Scroll after the keyboard is fully showing, with a short delay to allow things to settle (TextMessageField height update, for example)
debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.1)
}
.onChange(of: messageFieldFocused) {
if messageFieldFocused {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
}
}
// macOS doesn't have keyboard show animation, but we still want to scroll to the bottom.
debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.0)
}
}
TextMessageField(
destination: .channel(channel),

View file

@ -22,6 +22,7 @@ struct UserMessageList: View {
@State private var redrawTapbacksTrigger = UUID()
@AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1
@FetchRequest private var allPrivateMessages: FetchedResults<MessageEntity>
@State private var scrollToBottomWorkItem: DispatchWorkItem?
init(user: UserEntity) {
self.user = user
@ -41,8 +42,11 @@ struct UserMessageList: View {
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 context.hasChanges {
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),
@ -56,6 +60,17 @@ struct UserMessageList: View {
}
}
func debouncedScrollToBottom(scrollView: ScrollViewProxy, lastMessageId: Int64?, delay: TimeInterval = 0.1) {
scrollToBottomWorkItem?.cancel()
let scrollTarget: AnyHashable = lastMessageId != nil ? lastMessageId : "bottomAnchor"
let work = DispatchWorkItem {
scrollView.scrollTo(scrollTarget, anchor: .bottom)
}
scrollToBottomWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
}
var body: some View {
// Cast user.messageList to an array for easier indexing and ForEach.
let messages: [MessageEntity] = Array(allPrivateMessages)
@ -68,6 +83,8 @@ struct UserMessageList: View {
return dict
}()
let lastMessageId: Int64? = messages.last?.messageId
VStack {
ScrollViewReader { scrollView in
ScrollView {
@ -96,11 +113,10 @@ struct UserMessageList: View {
// So, run it in the main queue after everything saves and stabilizes
DispatchQueue.main.async {
markMessagesAsRead()
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
}
debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.1)
}
}
}
// Invisible spacer to detect reaching bottom
Color.clear
@ -112,12 +128,21 @@ struct UserMessageList: View {
.defaultScrollAnchorTopAlignment()
.defaultScrollAnchorBottomSizeChanges()
.scrollDismissesKeyboard(.immediately)
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
// Keyboard is about to appear: keyboard show animation hasn't quite started yet.
// Schedule an immediate scroll to the bottom message by its messageId, in order to force LazyVStack to render that cell if it isn't rendered already
debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.0)
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in
// Keyboard is fully visible.
// Scroll after the keyboard is fully showing, with a short delay to allow things to settle (TextMessageField height update, for example)
debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.1)
}
.onChange(of: messageFieldFocused) {
if messageFieldFocused {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
}
}
// macOS doesn't have keyboard show animation, but we still want to scroll to the bottom.
debouncedScrollToBottom(scrollView: scrollView, lastMessageId: lastMessageId, delay: 0.0)
}
}
}
TextMessageField(