timeline fixups

This commit is contained in:
Henry Hiles 2026-11-09 10:58:08 -05:00
commit ac31fbb74d
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
2 changed files with 46 additions and 7 deletions

View file

@ -9,6 +9,14 @@ import "package:nexus/models/room_chat.dart";
typedef JumpToEvent = Future<void> Function(int rowId); typedef JumpToEvent = Future<void> 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({ final class ChatScroll({
required final IList<int> historyRows, required final IList<int> historyRows,
required final IList<int> liveRows, required final IList<int> liveRows,
@ -20,7 +28,7 @@ final class ChatScroll({
required final JumpToEvent jumpToEvent, required final JumpToEvent jumpToEvent,
required final Future<void> Function() jumpToBottom, required final Future<void> Function() jumpToBottom,
required final GlobalKey Function(int eventRowId) keyFor, required final EventRowKey Function(int eventRowId) keyFor,
}) { }) {
factory use({ factory use({
required AsyncValue<RoomChat?> controllerData, required AsyncValue<RoomChat?> controllerData,
@ -42,9 +50,9 @@ final class ChatScroll({
final scrollController = useScrollController(); final scrollController = useScrollController();
final centerKey = useMemoized(GlobalKey.new); final centerKey = useMemoized(GlobalKey.new);
final itemKeys = useMemoized(() => <int, GlobalKey>{}, []); final itemKeys = useMemoized(() => <int, EventRowKey>{}, []);
GlobalKey keyFor(int eventRowId) => EventRowKey keyFor(int eventRowId) =>
itemKeys.putIfAbsent(eventRowId, GlobalKey.new); itemKeys.putIfAbsent(eventRowId, () => EventRowKey(eventRowId));
useEffect(() { useEffect(() {
if (anchorId.value == null) { if (anchorId.value == null) {
@ -117,6 +125,22 @@ final class ChatScroll({
final lastRowId = controllerData.value?.timeline.lastOrNull; final lastRowId = controllerData.value?.timeline.lastOrNull;
final previousLastRowId = useRef<int?>(null); final previousLastRowId = useRef<int?>(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(() { useEffect(() {
final previous = previousLastRowId.value; final previous = previousLastRowId.value;
previousLastRowId.value = lastRowId; previousLastRowId.value = lastRowId;
@ -141,6 +165,10 @@ final class ChatScroll({
anchorId.value = lastRowId; anchorId.value = lastRowId;
scrollController.position.correctPixels(0); 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; return null;
@ -154,9 +182,7 @@ final class ChatScroll({
if (!scrollController.hasClients) return; if (!scrollController.hasClients) return;
final position = scrollController.position; final position = scrollController.position;
updateAtBottom();
final isAtBottom = position.extentBefore <= 0;
if (isAtBottom != atBottom.value) atBottom.value = isAtBottom;
// contextualEvent updates immediately when jumping, but // contextualEvent updates immediately when jumping, but
// controllerData is only for the historical timeline once it loads // controllerData is only for the historical timeline once it loads

View file

@ -40,6 +40,16 @@ class const ChatTimeline({
isHighlighted: scroll.highlightedEvent == eventRowId, 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<int> rows, Key key) => switch (key) {
EventRowKey(:final rowId) => switch (rows.indexOf(rowId)) {
-1 => null,
final index => index,
},
_ => null,
};
@override @override
Widget build(BuildContext context) => LayoutBuilder( Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) => TweenAnimationBuilder( builder: (context, constraints) => TweenAnimationBuilder(
@ -66,6 +76,7 @@ class const ChatTimeline({
SuperSliverList.builder( SuperSliverList.builder(
itemCount: scroll.liveRows.length, itemCount: scroll.liveRows.length,
findChildIndexCallback: (key) => indexOf(scroll.liveRows, key),
itemBuilder: (_, index) => eventRow( itemBuilder: (_, index) => eventRow(
scroll.liveRows[index], scroll.liveRows[index],
index > 0 index > 0
@ -78,6 +89,8 @@ class const ChatTimeline({
SuperSliverList.builder( SuperSliverList.builder(
key: scroll.centerKey, key: scroll.centerKey,
itemCount: scroll.historyRows.length, itemCount: scroll.historyRows.length,
findChildIndexCallback: (key) =>
indexOf(scroll.historyRows, key),
itemBuilder: (_, index) => eventRow( itemBuilder: (_, index) => eventRow(
scroll.historyRows[index], scroll.historyRows[index],
scroll.historyRows.getOrNull(index + 1), scroll.historyRows.getOrNull(index + 1),