From 110caff170caadb03d8cee30621b309775924b98 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 24 Sep 2026 20:02:23 -0400 Subject: [PATCH] refactor chatview to have two sliver lists Co-authored-by: Erwan Leboucher --- lib/controllers/room_chat.dart | 7 - lib/helpers/hooks/chat_scroll.dart | 194 +++++++++++++---------- lib/widgets/room_chat/chat_timeline.dart | 115 +++++++------- lib/widgets/room_chat/room_chat.dart | 11 +- 4 files changed, 168 insertions(+), 159 deletions(-) diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index d761ecf..53d7f7f 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -29,16 +29,9 @@ class RoomChatController(final String roomId) if (!room.hasFetchedState) { final state = await client.getRoomState(.new(roomId: roomId)); - await ref.read(RoomsController.provider.notifier).addState(roomId, state); } - // 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(); - } - return room.timeline .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) .map((element) => element.value) diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index b0b39ca..1a27974 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -4,126 +4,148 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; import "package:super_sliver_list/super_sliver_list.dart"; -final class ChatScroll({ - required final ListController listController, +final class ChatScroll({ + required final IList historyItems, + required final IList liveItems, + required final GlobalKey centerKey, + required final ListController historyListController, + required final ListController liveListController, required final ScrollController scrollController, required final bool hasMore, + required final bool isLoadingOlder, required final Future Function() loadOlder, required final Future Function(String id) jumpToId, }) { - static ChatScroll use({ + static ChatScroll use({ required AsyncValue?> controllerData, required String Function(T item) id, required Future Function() loadOlder, - required bool Function() shouldLoadOlder, required Future Function() onReachedBottom, }) { - final listController = useRef(ListController()); + final historyListController = useRef(ListController()); + final liveListController = useRef(ListController()); final scrollController = useScrollController(); + final centerKey = useMemoized(GlobalKey.new); + final anchorId = useState(null); final hasMore = useState(true); - final topItemBeforeLoad = useState(null); - final loadingOlder = useRef(false); - final initialized = useRef(false); + final isLoadingOlder = useState(false); - Future loadOlderItems() async { - if (loadingOlder.value || !hasMore.value) return; - - if (controllerData case AsyncData(:final value?)) { - loadingOlder.value = true; - topItemBeforeLoad.value = value.firstOrNull == null - ? null - : id(value.first); - - try { - hasMore.value = await loadOlder(); - } finally { - loadingOlder.value = false; - } - } - } - - Future jumpToId(String itemId) async { - final index = - controllerData.value?.indexWhere((item) => id(item) == itemId) ?? -1; - - if (index == -1) return; - - listController.value.animateToItem( - index: index, - scrollController: scrollController, - alignment: 0.5, - duration: (_) => .new(milliseconds: 700), - curve: (_) => Curves.easeInOut, - ); - } + final anchorIdValue = anchorId.value; useEffect(() { - if (controllerData case AsyncData(:final value?)) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!scrollController.hasClients) return; - - if (!initialized.value) { - initialized.value = true; - - if (value.isNotEmpty) { - listController.value.jumpToItem( - index: value.length - 1, - scrollController: scrollController, - alignment: 1, - ); - } - return; - } - - final topItem = topItemBeforeLoad.value; - - if (topItem != null) { - final index = value.indexWhere((item) => id(item) == topItem); - - if (index != -1) { - listController.value.jumpToItem( - index: index, - scrollController: scrollController, - alignment: 0, - ); - } - - topItemBeforeLoad.value = null; - } else if (scrollController.position.atEdge && - scrollController.position.pixels != 0) { - scrollController.jumpTo(scrollController.position.maxScrollExtent); - } - }); + if (anchorId.value == null) { + if (controllerData case AsyncData(:final value?) + when value.isNotEmpty) { + anchorId.value = id(value.last); + } } return null; }, [controllerData]); + final ({IList history, IList live}) split = useMemoized(() { + final items = controllerData.value; + final anchor = anchorIdValue; + + if (items == null || anchor == null) { + return (history: const .empty(), live: const .empty()); + } + + final anchorIndex = items.indexWhere((item) => id(item) == anchor); + + if (anchorIndex == -1) { + return (history: const .empty(), live: items); + } + + return ( + history: items.take(anchorIndex).toIList().reversed.toIList(), + live: items.skip(anchorIndex).toIList(), + ); + }, [controllerData, anchorIdValue]); + + Future loadOlderItems() async { + if (!hasMore.value || isLoadingOlder.value) return; + + isLoadingOlder.value = true; + + try { + hasMore.value = await loadOlder(); + } finally { + isLoadingOlder.value = false; + } + } + + Future jumpToId(String itemId) async { + if (!scrollController.hasClients) return; + + final historyIndex = split.history.indexWhere( + (item) => id(item) == itemId, + ); + + if (historyIndex != -1) { + historyListController.value.animateToItem( + index: historyIndex, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => .new(milliseconds: 700), + curve: (_) => Curves.easeInOut, + ); + + return; + } + + final liveIndex = split.live.indexWhere((item) => id(item) == itemId); + + if (liveIndex != -1) { + liveListController.value.animateToItem( + index: liveIndex, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => .new(milliseconds: 700), + curve: (_) => Curves.easeInOut, + ); + } + } + useEffect(() { - Future listener() async { - if (!scrollController.hasClients || !scrollController.position.atEdge) { + const loadThreshold = 500.0; + const bottomThreshold = 50.0; + + void checkPosition() { + if (!scrollController.hasClients) { return; } - if (scrollController.position.pixels == 0) { - if (shouldLoadOlder()) { - await loadOlderItems(); + final position = scrollController.position; + + if (position.extentAfter <= loadThreshold) { + if (hasMore.value && !isLoadingOlder.value) { + loadOlderItems(); } - } else { - await onReachedBottom(); + } + + if (position.extentBefore <= bottomThreshold) { + onReachedBottom(); } } - scrollController.addListener(listener); + scrollController.addListener(checkPosition); - return () => scrollController.removeListener(listener); - }, [controllerData]); + WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition()); + + return () => scrollController.removeListener(checkPosition); + }, [scrollController, onReachedBottom]); return .new( - listController: listController.value, + historyItems: split.history, + liveItems: split.live, + centerKey: centerKey, + historyListController: historyListController.value, + liveListController: liveListController.value, scrollController: scrollController, hasMore: hasMore.value, + isLoadingOlder: isLoadingOlder.value, loadOlder: loadOlderItems, jumpToId: jumpToId, ); diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index 21f807f..09fcaf8 100644 --- a/lib/widgets/room_chat/chat_timeline.dart +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -1,76 +1,79 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:material_ui/material_ui.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/highlight_wrapper.dart"; -import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/widgets/loading.dart"; import "package:super_sliver_list/super_sliver_list.dart"; class const ChatTimeline({ - required final AsyncValue?> controllerData, - required final ScrollController scrollController, - required final ListController listController, - required final bool hasMore, - required final Future Function() loadOlder, + required final ChatScroll scroll, required final Future Function(String) jumpToId, required final IList Function(Event) getEventOptions, required final String? highlightedEvent, required final double composerHeight, super.key, }) extends StatelessWidget { + bool isGrouped(Event event, Event? previousEvent) => + previousEvent?.content is MessageContent && + previousEvent?.redactedBy == null && + previousEvent?.relationType != "m.replace" && + event.sender == previousEvent?.sender && + event.pmp?.id == previousEvent?.pmp?.id; + + Widget eventRow( + Event event, + Event? previousEvent, { + required Future Function(String) jumpToId, + required IList Function(Event) getEventOptions, + required String? highlightedEvent, + }) => HighlightWrapper( + EventRenderer( + event, + onTapReply: () => jumpToId(event.replyTo!), + getEventOptions: getEventOptions, + isGrouped: isGrouped(event, previousEvent), + ), + key: ValueKey(event.eventId), + isHighlighted: highlightedEvent == event.eventId, + ); + @override - Widget build(BuildContext context) => switch (controllerData) { - AsyncData(:final value?) || AsyncLoading(:final value?) => CustomScrollView( - keyboardDismissBehavior: .onDrag, - controller: scrollController, - slivers: [ - if (hasMore) - SliverToBoxAdapter( - child: Padding( - padding: .symmetric(vertical: 36), - child: Center( - child: ElevatedButton( - onPressed: controllerData is AsyncData ? loadOlder : null, - child: Text("Load More"), - ), - ), - ), - ), + Widget build(BuildContext context) => CustomScrollView( + reverse: true, + center: scroll.centerKey, + keyboardDismissBehavior: .onDrag, + controller: scroll.scrollController, + slivers: [ + SliverToBoxAdapter(child: SizedBox(height: composerHeight)), - SuperSliverList.builder( - listController: listController, - 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 == previousEvent?.sender && - event.pmp?.id == previousEvent?.pmp?.id, - ), - isHighlighted: highlightedEvent == event.eventId, - ); - }, + SuperSliverList.builder( + listController: scroll.liveListController, + itemCount: scroll.liveItems.length, + itemBuilder: (_, index) => eventRow( + scroll.liveItems[index], + index > 0 + ? scroll.liveItems.getOrNull(index - 1) + : scroll.historyItems.firstOrNull, + jumpToId: jumpToId, + getEventOptions: getEventOptions, + highlightedEvent: highlightedEvent, ), + ), - SliverPadding(padding: .only(bottom: composerHeight)), - ], - ), - AsyncData() => Center(child: Text("Nothing to see here...")), - AsyncLoading() => Loading(), - AsyncError(:final error, :final stackTrace) => ErrorDialog( - error, - stackTrace, - ), - }; + SuperSliverList.builder( + key: scroll.centerKey, + listController: scroll.historyListController, + itemCount: scroll.historyItems.length, + itemBuilder: (_, index) => eventRow( + scroll.historyItems[index], + scroll.historyItems.getOrNull(index), + jumpToId: jumpToId, + getEventOptions: getEventOptions, + highlightedEvent: highlightedEvent, + ), + ), + ], + ); } diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 46d4d23..a7fe9e5 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -70,11 +70,6 @@ final class const RoomChat({ controllerData: controllerData, id: (event) => event.eventId, loadOlder: notifier.loadOlder, - shouldLoadOlder: () => ref.read( - RoomsController.provider.select( - (rooms) => rooms[roomId]?.hasMore ?? false, - ), - ), onReachedBottom: () async { final room = ref.read( RoomsController.provider.select((rooms) => rooms[roomId]), @@ -149,11 +144,7 @@ final class const RoomChat({ child: Padding( padding: .symmetric(horizontal: 4), child: ChatTimeline( - controllerData: controllerData, - scrollController: scroll.scrollController, - listController: scroll.listController, - hasMore: scroll.hasMore, - loadOlder: scroll.loadOlder, + scroll: scroll, jumpToId: jumpToId, getEventOptions: getEventOptions, highlightedEvent: highlightedEvent.value,