From 0f364d76132973d468110d7bd0b52461ad7e76e5 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sun, 9 Aug 2026 23:13:54 +0200 Subject: [PATCH 1/4] fix: timeline pagination --- lib/controllers/room_chat.dart | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 3e531bb..98a3fd9 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -17,27 +17,31 @@ import "package:nexus/models/room.dart"; class RoomChatController extends AsyncNotifier?> { final String roomId; + Future? _loadingOlder; RoomChatController(this.roomId); @override Future?> build() async { final client = ref.watch(ClientController.provider.notifier); - final room = ref.watch( + final initialRoom = ref.watch( RoomsController.provider.select((rooms) => rooms[roomId]), ); - if (room == null) return null; + if (initialRoom == null) return null; + Room room = initialRoom; if (!room.hasFetchedState) { final state = await client.getRoomState(.new(roomId: roomId)); await ref.read(RoomsController.provider.notifier).addState(roomId, state); + room = ref.read(RoomsController.provider)[roomId] ?? room; } // While there are under 20 events, try to load more // until there's no more or the conditions are met. if (room.hasMore && room.timeline.length < 20) { - loadOlder(); + await loadOlder(); + room = ref.read(RoomsController.provider)[roomId] ?? room; } return room.timeline @@ -74,18 +78,22 @@ class RoomChatController extends AsyncNotifier?> { ), ); - Future loadOlder() async { - final timelineKeys = ref - .read(RoomsController.provider.select((value) => value[roomId])) - ?.timeline - .keys; + Future loadOlder() => _loadingOlder ??= _loadOlder().whenComplete(() { + _loadingOlder = null; + }); + + Future _loadOlder() async { + final room = ref.read(RoomsController.provider)[roomId]; + if (room == null || !room.hasMore) return false; + + final timelineKeys = room.timeline.keys; final response = await ref .watch(ClientController.provider.notifier) .paginate( .new( roomId: roomId, - maxTimelineId: timelineKeys?.isNotEmpty == true - ? timelineKeys?.reduce(min) + maxTimelineId: timelineKeys.isNotEmpty + ? timelineKeys.reduce(min) : null, ), ); From 3eb39e6782e4d9a6dfb7e218d8a3fd86ca5dff46 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sun, 9 Aug 2026 23:14:02 +0200 Subject: [PATCH 2/4] fix: timeline anchoring --- lib/widgets/room_chat.dart | 320 +++++++++++++++++++++---------------- 1 file changed, 183 insertions(+), 137 deletions(-) diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index fcab5fe..97afca2 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -72,23 +72,47 @@ class RoomChat extends HookConsumerWidget { final controllerProvider = RoomChatController.provider(roomId); final notifier = ref.watch(controllerProvider.notifier); - final client = ref.watch(ClientController.provider.notifier); - final listController = useRef(ListController()); final scrollController = useScrollController(); final controllerData = ref.watch(controllerProvider); - - final topEventBeforeLoad = useState(null); + final historyListController = useRef(ListController()); + final liveListController = useRef(ListController()); + final timelineCenterKey = useMemoized(GlobalKey.new); + final timelineBoundary = useRef(null); + final hasMore = ref.watch( + RoomsController.provider.select( + (rooms) => rooms[roomId]?.hasMore ?? false, + ), + ); Future jumpToId(String eventId) async { - final index = controllerData.value?.indexWhere( - (element) => element.eventId == eventId, - ); - if (index == null) return; + var events = ref.read(controllerProvider).value; + while (events?.any((event) => event.eventId == eventId) != true) { + final room = ref.read(RoomsController.provider)[roomId]; + if (room == null || !room.hasMore) return; + await notifier.loadOlder(); + await WidgetsBinding.instance.endOfFrame; + events = ref.read(controllerProvider).value; + } + if (events == null || !scrollController.hasClients) return; - listController.value.animateToItem( - index: index, + final sourceIndex = events.indexWhere( + (event) => event.eventId == eventId, + ); + final boundaryIndex = events.indexWhere( + (event) => event.eventId == timelineBoundary.value, + ); + if (sourceIndex < 0 || boundaryIndex < 0) return; + + final listController = sourceIndex <= boundaryIndex + ? historyListController.value + : liveListController.value; + if (!listController.isAttached) return; + listController.animateToItem( + index: sourceIndex <= boundaryIndex + ? boundaryIndex - sourceIndex + : sourceIndex - boundaryIndex - 1, scrollController: scrollController, alignment: 0.5, duration: (_) => .new(milliseconds: 700), @@ -103,87 +127,11 @@ class RoomChat extends HookConsumerWidget { } Future loadOlder() async { - if (controllerData case AsyncData(:final value?)) { - topEventBeforeLoad.value = value.firstOrNull?.eventId; + if (controllerData case AsyncData(value: final _?)) { await notifier.loadOlder(); } } - useEffect(() { - ref - .read(controllerProvider.future) - .then( - (_) => WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - scrollController.jumpTo( - scrollController.position.maxScrollExtent - .000001, - ); - } - }), - ); - - return null; - }, [scrollController.hasClients]); - - useEffect(() { - if (controllerData case AsyncData( - :final value?, - ) when scrollController.hasClients) { - if (topEventBeforeLoad.value != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - final index = value.indexWhere( - (event) => event.eventId == topEventBeforeLoad.value, - ); - if (index != -1) { - listController.value.jumpToItem( - index: index, - scrollController: scrollController, - alignment: 0, - ); - } - } - topEventBeforeLoad.value = null; - }); - } else if (scrollController.position.atEdge && - scrollController.position.pixels != 0) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - scrollController.jumpTo( - scrollController.position.maxScrollExtent, - ); - } - }); - } - } - - return null; - }, [controllerData]); - - useEffect(() { - Future listener() async { - if (!scrollController.hasClients || !scrollController.position.atEdge) { - return; - } - - final room = ref.watch( - RoomsController.provider.select((value) => value[roomId]), - ); - if (room == null) return; - - if (scrollController.position.pixels == 0) { - if (room.hasMore) { - await loadOlder(); - } - } else { - await client.markRead(room); - } - } - - scrollController.addListener(listener); - return () => scrollController.removeListener(listener); - }, [roomId, controllerData]); - final composerNode = useFocusNode( onKeyEvent: (_, event) { if (event is KeyDownEvent && event.logicalKey == .escape) { @@ -465,58 +413,117 @@ class RoomChat extends HookConsumerWidget { padding: .symmetric(horizontal: 4), child: switch (controllerData) { AsyncData(:final value?) || - AsyncLoading(:final value?) => CustomScrollView( - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - controller: scrollController, - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: .symmetric(vertical: 36), - child: Center( - child: ElevatedButton( - onPressed: controllerData is AsyncData - ? loadOlder - : null, - child: Text("Load More"), + AsyncLoading(:final value?) => Builder( + builder: (_) { + if (value.isEmpty) return nothing; + var boundaryIndex = value.indexWhere( + (event) => + event.eventId == timelineBoundary.value, + ); + if (boundaryIndex < 0) { + boundaryIndex = value.length - 1; + timelineBoundary.value = + value[boundaryIndex].eventId; + } + + final historyLength = boundaryIndex + 1; + final liveLength = value.length - historyLength; + + int? historyChildIndex(Key key) { + if (key == const ValueKey("history-loader")) { + return historyLength; + } + if (key is! ValueKey) return null; + final eventIndex = value.indexWhere( + (event) => event.eventId == key.value, + ); + return eventIndex < 0 || + eventIndex >= historyLength + ? null + : historyLength - eventIndex - 1; + } + + int? liveChildIndex(Key key) { + if (key is! ValueKey) return null; + final eventIndex = value.indexWhere( + (event) => event.eventId == key.value, + ); + return eventIndex < historyLength + ? null + : eventIndex - historyLength; + } + + Widget eventRow( + Event event, + Event? previousEvent, + ) => HighlightWrapper( + EventRenderer( + event, + onTapReply: () => jumpToId(event.replyTo!), + getEventOptions: getEventOptions, + isGrouped: + previousEvent?.content + is MessageContent && + previousEvent?.redactedBy == null && + previousEvent?.relationType != + "m.replace" && + "${event.sender}${event.pmp?.id}" == + "${previousEvent?.sender}${previousEvent?.pmp?.id}", + ), + key: ValueKey(event.eventId), + isHighlighted: + highlightedEvent.value == event.eventId, + ); + + return CustomScrollView( + reverse: true, + center: timelineCenterKey, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: SizedBox(height: composerSize.value), + ), + SuperSliverList.builder( + listController: liveListController.value, + itemCount: liveLength, + findChildIndexCallback: liveChildIndex, + itemBuilder: (_, index) => eventRow( + value[historyLength + index], + value.getOrNull( + historyLength + index - 1, + ), ), ), - ), - ), - - SuperSliverList.builder( - listController: listController.value, - itemCount: value.length, - itemBuilder: (_, index) { - final event = value[index]; - final previousEvent = value.getOrNull( - index - 1, - ); - return HighlightWrapper( - EventRenderer( - event, - onTapReply: () => - jumpToId(event.replyTo!), - getEventOptions: getEventOptions, - isGrouped: - previousEvent?.content - is MessageContent && - previousEvent?.redactedBy == null && - previousEvent?.relationType != - "m.replace" && - "${event.sender}${event.pmp?.id}" == - "${previousEvent?.sender}${previousEvent?.pmp?.id}", - ), - isHighlighted: - highlightedEvent.value == event.eventId, - ); - }, - ), - - SliverPadding( - padding: .only(bottom: composerSize.value), - ), - ], + SuperSliverList.builder( + key: timelineCenterKey, + listController: historyListController.value, + itemCount: historyLength + 1, + findChildIndexCallback: historyChildIndex, + itemBuilder: (_, index) { + if (index == historyLength) { + return _HistoryLoader( + key: const ValueKey("history-loader"), + hasMore: hasMore, + isLoading: + controllerData is AsyncLoading, + onLoad: loadOlder, + ); + } + final event = + value[historyLength - index - 1]; + return eventRow( + event, + value.getOrNull( + historyLength - index - 2, + ), + ); + }, + ), + ], + ); + }, ), AsyncData() => nothing, AsyncLoading() => Loading(), @@ -564,3 +571,42 @@ class RoomChat extends HookConsumerWidget { ); } } + +class _HistoryLoader extends HookWidget { + final bool hasMore; + final bool isLoading; + final Future Function() onLoad; + + const _HistoryLoader({ + required this.hasMore, + required this.isLoading, + required this.onLoad, + super.key, + }); + + @override + Widget build(BuildContext context) { + useEffect(() { + if (hasMore) { + Future.microtask(onLoad); + } + return null; + }, const []); + + return Padding( + padding: .symmetric(vertical: 36), + child: Center( + child: ElevatedButton( + onPressed: hasMore && !isLoading ? onLoad : null, + child: isLoading + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(hasMore ? "Load More" : "Start of timeline"), + ), + ), + ); + } +} From 1723a0691a803064f25378eda6aa556767b677a6 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sun, 9 Aug 2026 23:13:54 +0200 Subject: [PATCH 3/4] fix: timeline pagination --- lib/controllers/room_chat.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 98a3fd9..75ce7d8 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -37,8 +37,7 @@ class RoomChatController extends AsyncNotifier?> { room = ref.read(RoomsController.provider)[roomId] ?? room; } - // While there are under 20 events, try to load more - // until there's no more or the conditions are met. + // Load one more page when the initial timeline is short. if (room.hasMore && room.timeline.length < 20) { await loadOlder(); room = ref.read(RoomsController.provider)[roomId] ?? room; From 1d6a98f165fa63ad26b4437d08e471dbad121dc8 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sun, 9 Aug 2026 23:14:02 +0200 Subject: [PATCH 4/4] fix: timeline anchoring --- lib/widgets/room_chat.dart | 40 ++++++++++++++------------------------ 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 97afca2..f50db27 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -87,14 +87,7 @@ class RoomChat extends HookConsumerWidget { ); Future jumpToId(String eventId) async { - var events = ref.read(controllerProvider).value; - while (events?.any((event) => event.eventId == eventId) != true) { - final room = ref.read(RoomsController.provider)[roomId]; - if (room == null || !room.hasMore) return; - await notifier.loadOlder(); - await WidgetsBinding.instance.endOfFrame; - events = ref.read(controllerProvider).value; - } + final events = controllerData.value; if (events == null || !scrollController.hasClients) return; final sourceIndex = events.indexWhere( @@ -126,12 +119,6 @@ class RoomChat extends HookConsumerWidget { }); } - Future loadOlder() async { - if (controllerData case AsyncData(value: final _?)) { - await notifier.loadOlder(); - } - } - final composerNode = useFocusNode( onKeyEvent: (_, event) { if (event is KeyDownEvent && event.logicalKey == .escape) { @@ -429,26 +416,29 @@ class RoomChat extends HookConsumerWidget { final historyLength = boundaryIndex + 1; final liveLength = value.length - historyLength; + int? eventIndexForKey(Key key) { + if (key is! ValueKey) return null; + final index = value.indexWhere( + (event) => event.eventId == key.value, + ); + return index < 0 ? null : index; + } + int? historyChildIndex(Key key) { if (key == const ValueKey("history-loader")) { return historyLength; } - if (key is! ValueKey) return null; - final eventIndex = value.indexWhere( - (event) => event.eventId == key.value, - ); - return eventIndex < 0 || + final eventIndex = eventIndexForKey(key); + return eventIndex == null || eventIndex >= historyLength ? null : historyLength - eventIndex - 1; } int? liveChildIndex(Key key) { - if (key is! ValueKey) return null; - final eventIndex = value.indexWhere( - (event) => event.eventId == key.value, - ); - return eventIndex < historyLength + final eventIndex = eventIndexForKey(key); + return eventIndex == null || + eventIndex < historyLength ? null : eventIndex - historyLength; } @@ -508,7 +498,7 @@ class RoomChat extends HookConsumerWidget { hasMore: hasMore, isLoading: controllerData is AsyncLoading, - onLoad: loadOlder, + onLoad: notifier.loadOlder, ); } final event =