From 3ea2e4763e1982b72e653b19b7c0a82fc1582f7b Mon Sep 17 00:00:00 2001 From: Dmitry Polshakov Date: Sat, 25 Apr 2026 09:00:22 +0300 Subject: [PATCH] fix(chat): fix jump-to-oldest-unread scroll not reaching target message - Pass initialUnreadCount to chat screens before markRead clears it - Use two-phase scroll: jumpTo estimated offset to build lazy items, then ensureVisible for precise positioning - Await ensureVisible before clearing scroll guard to prevent scrollToBottomIfAtBottom from overriding the animation Co-Authored-By: Claude Opus 4.6 --- lib/helpers/chat_scroll_controller.dart | 19 +++++++++ lib/screens/channel_chat_screen.dart | 24 +++++++---- lib/screens/channels_screen.dart | 7 +++- lib/screens/chat_screen.dart | 55 +++++++++++++++++-------- lib/screens/contacts_screen.dart | 22 ++++++++-- lib/screens/map_screen.dart | 13 ++++-- 6 files changed, 107 insertions(+), 33 deletions(-) diff --git a/lib/helpers/chat_scroll_controller.dart b/lib/helpers/chat_scroll_controller.dart index d2c73fbf..c0d19747 100644 --- a/lib/helpers/chat_scroll_controller.dart +++ b/lib/helpers/chat_scroll_controller.dart @@ -49,6 +49,25 @@ class ChatScrollController extends ScrollController { } } + /// Jumps toward an off-screen message so that lazy ListView.builder builds + /// items near it. Only visible + cacheExtent items have real heights, so we + /// use proportion of maxScrollExtent (itself an estimate from built items' + /// avg height). Call [onJumped] on the next frame to ensureVisible/scroll + /// to the exact target. + void jumpToEstimatedOffset({ + required int unreadCount, + required int totalMessages, + required VoidCallback onJumped, + }) { + if (!hasClients || totalMessages == 0) return; + final maxExtent = position.maxScrollExtent; + final jumpOffset = maxExtent * (unreadCount / totalMessages); + if (jumpOffset > 100) { + jumpTo(jumpOffset); + } + WidgetsBinding.instance.addPostFrameCallback((_) => onJumped()); + } + void scrollToBottomIfAtBottom() { // Only scroll if jump button is NOT showing (i.e., already at bottom) if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) { diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index e5b5f67e..9a17a2bc 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -37,8 +37,13 @@ import 'map_screen.dart'; class ChannelChatScreen extends StatefulWidget { final Channel channel; + final int initialUnreadCount; - const ChannelChatScreen({super.key, required this.channel}); + const ChannelChatScreen({ + super.key, + required this.channel, + this.initialUnreadCount = 0, + }); @override State createState() => _ChannelChatScreenState(); @@ -66,13 +71,11 @@ class _ChannelChatScreenState extends State { final connector = context.read(); final settings = context.read().settings; final idx = widget.channel.index; - final unread = connector.getUnreadCountForChannelIndex(idx); + final unread = widget.initialUnreadCount; + final messages = connector.getChannelMessages(widget.channel); ChannelMessage? anchor; if (settings.jumpToOldestUnread && unread > 0) { - anchor = _findOldestUnreadChannelAnchor( - connector.getChannelMessages(widget.channel), - unread, - ); + anchor = _findOldestUnreadChannelAnchor(messages, unread); } connector.setActiveChannel(idx); _connector = connector; @@ -80,7 +83,14 @@ class _ChannelChatScreenState extends State { _channelSkipNextBottomSnap = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _scrollToMessage(anchor!.messageId); + _scrollController.jumpToEstimatedOffset( + unreadCount: unread, + totalMessages: messages.length, + onJumped: () { + if (!mounted) return; + _scrollToMessage(anchor!.messageId); + }, + ); }); } }); diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 44c7a69c..9faccf8d 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -492,13 +492,18 @@ class _ChannelsScreenState extends State ], ), onTap: () async { + final unread = + connector.getUnreadCountForChannelIndex(channel.index); connector.markChannelRead(channel.index); await Future.delayed(const Duration(milliseconds: 50)); if (context.mounted) { Navigator.push( context, MaterialPageRoute( - builder: (context) => ChannelChatScreen(channel: channel), + builder: (context) => ChannelChatScreen( + channel: channel, + initialUnreadCount: unread, + ), ), ); } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 2aee61c3..e1b81b61 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -48,8 +48,13 @@ import 'telemetry_screen.dart'; class ChatScreen extends StatefulWidget { final Contact contact; + final int initialUnreadCount; - const ChatScreen({super.key, required this.contact}); + const ChatScreen({ + super.key, + required this.contact, + this.initialUnreadCount = 0, + }); @override State createState() => _ChatScreenState(); @@ -75,13 +80,11 @@ class _ChatScreenState extends State { final connector = context.read(); final settings = context.read().settings; final keyHex = widget.contact.publicKeyHex; - final unread = connector.getUnreadCountForContactKey(keyHex); + final unread = widget.initialUnreadCount; + final messages = connector.getMessages(widget.contact); Message? anchor; if (settings.jumpToOldestUnread && unread > 0) { - anchor = _findOldestUnreadAnchor( - connector.getMessages(widget.contact), - unread, - ); + anchor = _findOldestUnreadAnchor(messages, unread); } connector.setActiveContact(keyHex); _connector = connector; @@ -89,15 +92,24 @@ class _ChatScreenState extends State { setState(() => _pendingUnreadScrollTarget = anchor); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - final ctx = _unreadScrollKey.currentContext; - if (ctx != null) { - Scrollable.ensureVisible( - ctx, - duration: const Duration(milliseconds: 350), - alignment: 0.15, - ); - } - setState(() => _pendingUnreadScrollTarget = null); + _scrollController.jumpToEstimatedOffset( + unreadCount: unread, + totalMessages: messages.length, + onJumped: () async { + if (!mounted) return; + final ctx = _unreadScrollKey.currentContext; + if (ctx != null) { + await Scrollable.ensureVisible( + ctx, + duration: const Duration(milliseconds: 350), + alignment: 0.15, + ); + } + if (mounted) { + setState(() => _pendingUnreadScrollTarget = null); + } + }, + ); }); } }); @@ -1305,11 +1317,18 @@ class _ChatScreenState extends State { } void _openChat(BuildContext context, Contact contact) { - // Check if this is a repeater - context.read().markContactRead(contact.publicKeyHex); + final connector = context.read(); + final unread = + connector.getUnreadCountForContactKey(contact.publicKeyHex); + connector.markContactRead(contact.publicKeyHex); Navigator.push( context, - MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)), + MaterialPageRoute( + builder: (context) => ChatScreen( + contact: contact, + initialUnreadCount: unread, + ), + ), ); } diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 54d32990..601d44ea 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -930,10 +930,18 @@ class _ContactsScreenState extends State } else if (contact.type == advTypeRoom) { _showRoomLogin(context, contact, RoomLoginDestination.chat); } else { - context.read().markContactRead(contact.publicKeyHex); + final connector = context.read(); + final unread = + connector.getUnreadCountForContactKey(contact.publicKeyHex); + connector.markContactRead(contact.publicKeyHex); Navigator.push( context, - MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)), + MaterialPageRoute( + builder: (context) => ChatScreen( + contact: contact, + initialUnreadCount: unread, + ), + ), ); } } @@ -988,7 +996,10 @@ class _ContactsScreenState extends State builder: (context) => RoomLoginDialog( room: room, onLogin: (password, isAdmin) { - context.read().markContactRead(room.publicKeyHex); + final connector = context.read(); + final unread = + connector.getUnreadCountForContactKey(room.publicKeyHex); + connector.markContactRead(room.publicKeyHex); Navigator.push( context, MaterialPageRoute( @@ -999,7 +1010,10 @@ class _ContactsScreenState extends State password: password, isAdmin: isAdmin, ) - : ChatScreen(contact: room), + : ChatScreen( + contact: room, + initialUnreadCount: unread, + ), ), ); }, diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 6a8acda7..36a9fd64 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1391,11 +1391,18 @@ class _MapScreenState extends State { room: room, // onLogin(password, isAdmin) isAdmin not used for room caht screen onLogin: (password, _) { - // Navigate to chat screen after successful login - context.read().markContactRead(room.publicKeyHex); + final connector = context.read(); + final unread = + connector.getUnreadCountForContactKey(room.publicKeyHex); + connector.markContactRead(room.publicKeyHex); Navigator.push( context, - MaterialPageRoute(builder: (context) => ChatScreen(contact: room)), + MaterialPageRoute( + builder: (context) => ChatScreen( + contact: room, + initialUnreadCount: unread, + ), + ), ); }, ),