fix timeline weirdness with zero extents

This commit is contained in:
Henry Hiles 2026-11-09 12:03:52 -05:00
commit ecac985d90
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
3 changed files with 20 additions and 77 deletions

View file

@ -3,7 +3,6 @@ import "dart:math";
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:fluttertagger/fluttertagger.dart"; import "package:fluttertagger/fluttertagger.dart";
import "package:nexus/controllers/attachment.dart"; import "package:nexus/controllers/attachment.dart";
@ -84,14 +83,6 @@ class RoomChatController(final ChatParams info)
.where((rowId) => room.events[rowId]?.threadRoot == null) .where((rowId) => room.events[rowId]?.threadRoot == null)
.toIList(); .toIList();
// TODO: remove, debugging replies not showing
debugPrint(
"[timeline] build ${info.roomId}: "
"syncedTail=${room.timeline.entries.sortedBy<num>((entry) => entry.key).reversed.take(5).map((entry) => "${entry.key}:${entry.value}").toList()} "
"clientSticky=${room.clientSticky.toList()} "
"chatTail=${timeline.reversed.take(5).toList()}",
);
if (info.contextualEvent == null || if (info.contextualEvent == null ||
timeline.contains(info.contextualEvent)) { timeline.contains(info.contextualEvent)) {
ref.watch( ref.watch(
@ -465,11 +456,6 @@ class RoomChatController(final ChatParams info)
/// Shows [event], which was just sent, until it comes down sync. /// Shows [event], which was just sent, until it comes down sync.
void addSent(Event? event) { void addSent(Event? event) {
// TODO: remove, debugging replies not showing
debugPrint(
"[timeline] addSent: rowId=${event?.rowId} eventId=${event?.eventId} "
"alreadySynced=${ref.read(RoomsController.provider)[info.roomId]?.timeline.values.contains(event?.rowId)}",
);
if (event case final event?) { if (event case final event?) {
ref ref
.read(RoomsController.provider.notifier) .read(RoomsController.provider.notifier)

View file

@ -9,14 +9,6 @@ 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,
@ -28,7 +20,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 EventRowKey Function(int eventRowId) keyFor, required final GlobalKey Function(int eventRowId) keyFor,
}) { }) {
factory use({ factory use({
required AsyncValue<RoomChat?> controllerData, required AsyncValue<RoomChat?> controllerData,
@ -50,9 +42,9 @@ final class ChatScroll({
final scrollController = useScrollController(); final scrollController = useScrollController();
final centerKey = useMemoized(GlobalKey.new); final centerKey = useMemoized(GlobalKey.new);
final itemKeys = useMemoized(() => <int, EventRowKey>{}, []); final itemKeys = useMemoized(() => <int, GlobalKey>{}, []);
EventRowKey keyFor(int eventRowId) => GlobalKey keyFor(int eventRowId) =>
itemKeys.putIfAbsent(eventRowId, () => EventRowKey(eventRowId)); itemKeys.putIfAbsent(eventRowId, GlobalKey.new);
useEffect(() { useEffect(() {
if (anchorId.value == null) { if (anchorId.value == null) {
@ -109,13 +101,6 @@ final class ChatScroll({
final anchorIndex = items.indexOf(anchor); final anchorIndex = items.indexOf(anchor);
final historyLength = anchorIndex == -1 ? items.length : anchorIndex + 1; final historyLength = anchorIndex == -1 ? items.length : anchorIndex + 1;
// TODO: remove, debugging replies not showing
debugPrint(
"[timeline] split: anchor=$anchor anchorIndex=$anchorIndex "
"length=${items.length} "
"historyTail=${items.take(historyLength).toList().reversed.take(3).toList()} "
"live=${items.skip(historyLength).toList()}",
);
return ( return (
history: items.take(historyLength).toIList().reversed.toIList(), history: items.take(historyLength).toIList().reversed.toIList(),
live: items.skip(historyLength).toIList(), live: items.skip(historyLength).toIList(),
@ -128,27 +113,13 @@ final class ChatScroll({
void updateAtBottom() { void updateAtBottom() {
if (!hookContext.mounted || !scrollController.hasClients) return; if (!hookContext.mounted || !scrollController.hasClients) return;
final position = scrollController.position; final isAtBottom = scrollController.position.extentBefore <= 0;
final isAtBottom = position.extentBefore <= 0; if (isAtBottom != atBottom.value) atBottom.value = isAtBottom;
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;
// TODO: remove, debugging replies not showing
debugPrint(
"[timeline] lastRowId: previous=$previous last=$lastRowId "
"atBottom=${atBottom.value} contextualEvent=${contextualEvent.value}",
);
if (previous == null || lastRowId == null || previous == lastRowId) { if (previous == null || lastRowId == null || previous == lastRowId) {
return null; return null;
@ -156,11 +127,6 @@ final class ChatScroll({
if (!atBottom.value || contextualEvent.value != null) return null; if (!atBottom.value || contextualEvent.value != null) return null;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// TODO: remove, debugging replies not showing
debugPrint(
"[timeline] re-anchoring to $lastRowId "
"hasClients=${scrollController.hasClients}",
);
if (!scrollController.hasClients) return; if (!scrollController.hasClients) return;
anchorId.value = lastRowId; anchorId.value = lastRowId;

View file

@ -25,31 +25,25 @@ class const ChatTimeline({
int? previousEventRowId, { int? previousEventRowId, {
required Key key, required Key key,
}) => HighlightWrapper( }) => HighlightWrapper(
EventRenderer( // To work around an AWFUL SuperSliverList bug
eventRowId, ConstrainedBox(
previousEventRowId: previousEventRowId, constraints: const BoxConstraints(minHeight: 0.01),
roomId: chat.roomId, child: EventRenderer(
jumpToEvent: jumpToEvent, eventRowId,
onOpenThread: onOpenThread, previousEventRowId: previousEventRowId,
getEventOptions: getEventOptions, roomId: chat.roomId,
onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId), jumpToEvent: jumpToEvent,
readReceiptsChat: chat, onOpenThread: onOpenThread,
threadRoot: chat.threadRoot, getEventOptions: getEventOptions,
onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId),
readReceiptsChat: chat,
threadRoot: chat.threadRoot,
),
), ),
key: key, key: key,
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(
@ -76,7 +70,6 @@ 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
@ -89,8 +82,6 @@ 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),