From ac31fbb74d7232aca280db59aa3fd26b07b7a564 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 9 Nov 2026 10:58:08 -0500 Subject: [PATCH] timeline fixups --- lib/helpers/hooks/chat_scroll.dart | 40 +++++++++++++++++++----- lib/widgets/room_chat/chat_timeline.dart | 13 ++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index 189b5ed..9e8f8de 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -9,6 +9,14 @@ import "package:nexus/models/room_chat.dart"; typedef JumpToEvent = Future Function(int rowId); +/// Identifies the row for the event with [rowId], so it can be found to +/// scroll to it, and keeps its element when rows are added before it. +final class EventRowKey extends GlobalKey { + EventRowKey(this.rowId) : super.constructor(); + + final int rowId; +} + final class ChatScroll({ required final IList historyRows, required final IList liveRows, @@ -20,7 +28,7 @@ final class ChatScroll({ required final JumpToEvent jumpToEvent, required final Future Function() jumpToBottom, - required final GlobalKey Function(int eventRowId) keyFor, + required final EventRowKey Function(int eventRowId) keyFor, }) { factory use({ required AsyncValue controllerData, @@ -42,9 +50,9 @@ final class ChatScroll({ final scrollController = useScrollController(); final centerKey = useMemoized(GlobalKey.new); - final itemKeys = useMemoized(() => {}, []); - GlobalKey keyFor(int eventRowId) => - itemKeys.putIfAbsent(eventRowId, GlobalKey.new); + final itemKeys = useMemoized(() => {}, []); + EventRowKey keyFor(int eventRowId) => + itemKeys.putIfAbsent(eventRowId, () => EventRowKey(eventRowId)); useEffect(() { if (anchorId.value == null) { @@ -117,6 +125,22 @@ final class ChatScroll({ final lastRowId = controllerData.value?.timeline.lastOrNull; final previousLastRowId = useRef(null); + void updateAtBottom() { + if (!hookContext.mounted || !scrollController.hasClients) return; + + final position = scrollController.position; + final isAtBottom = position.extentBefore <= 0; + if (isAtBottom != atBottom.value) { + // TODO: remove, debugging replies not showing + debugPrint( + "[timeline] atBottom: $isAtBottom pixels=${position.pixels} " + "min=${position.minScrollExtent} anchor=${anchorId.value} " + "live=${split.live.length}", + ); + atBottom.value = isAtBottom; + } + } + useEffect(() { final previous = previousLastRowId.value; previousLastRowId.value = lastRowId; @@ -141,6 +165,10 @@ final class ChatScroll({ anchorId.value = lastRowId; scrollController.position.correctPixels(0); + + // Other callbacks this frame still see the new event below the old + // anchor, so they think it isn't at the bottom until the next frame + WidgetsBinding.instance.addPostFrameCallback((_) => updateAtBottom()); }); return null; @@ -154,9 +182,7 @@ final class ChatScroll({ if (!scrollController.hasClients) return; final position = scrollController.position; - - final isAtBottom = position.extentBefore <= 0; - if (isAtBottom != atBottom.value) atBottom.value = isAtBottom; + updateAtBottom(); // contextualEvent updates immediately when jumping, but // controllerData is only for the historical timeline once it loads diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index 40cc2e6..5a69b49 100644 --- a/lib/widgets/room_chat/chat_timeline.dart +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -40,6 +40,16 @@ class const ChatTimeline({ isHighlighted: scroll.highlightedEvent == eventRowId, ); + /// Where the row with [key] now is in [rows], so its element moves with it + /// when rows are added before it, rather than being rebuilt by index. + int? indexOf(IList rows, Key key) => switch (key) { + EventRowKey(:final rowId) => switch (rows.indexOf(rowId)) { + -1 => null, + final index => index, + }, + _ => null, + }; + @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) => TweenAnimationBuilder( @@ -66,6 +76,7 @@ class const ChatTimeline({ SuperSliverList.builder( itemCount: scroll.liveRows.length, + findChildIndexCallback: (key) => indexOf(scroll.liveRows, key), itemBuilder: (_, index) => eventRow( scroll.liveRows[index], index > 0 @@ -78,6 +89,8 @@ class const ChatTimeline({ SuperSliverList.builder( key: scroll.centerKey, itemCount: scroll.historyRows.length, + findChildIndexCallback: (key) => + indexOf(scroll.historyRows, key), itemBuilder: (_, index) => eventRow( scroll.historyRows[index], scroll.historyRows.getOrNull(index + 1),