From 7b6a4b4181904fe798f3cd97252b23cfb8bd1dde Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 10 Aug 2026 09:02:17 +0200 Subject: [PATCH] fix: timeline anchoring --- lib/widgets/room_chat.dart | 320 +++++++++++++++++++++---------------- 1 file changed, 178 insertions(+), 142 deletions(-) diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index fcab5fe..f50db27 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -72,23 +72,40 @@ 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; + final events = controllerData.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), @@ -102,88 +119,6 @@ class RoomChat extends HookConsumerWidget { }); } - Future loadOlder() async { - if (controllerData case AsyncData(:final value?)) { - topEventBeforeLoad.value = value.firstOrNull?.eventId; - 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 +400,120 @@ 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? 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; + } + final eventIndex = eventIndexForKey(key); + return eventIndex == null || + eventIndex >= historyLength + ? null + : historyLength - eventIndex - 1; + } + + int? liveChildIndex(Key key) { + final eventIndex = eventIndexForKey(key); + return eventIndex == null || + 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: notifier.loadOlder, + ); + } + final event = + value[historyLength - index - 1]; + return eventRow( + event, + value.getOrNull( + historyLength - index - 2, + ), + ); + }, + ), + ], + ); + }, ), AsyncData() => nothing, AsyncLoading() => Loading(), @@ -564,3 +561,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"), + ), + ), + ); + } +}