From eeb2e2f54b89b7f1a1a7f9b5ea3cce92496aebc9 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 24 Sep 2026 18:10:30 -0400 Subject: [PATCH] extract scroll helpers into their own hook --- lib/helpers/hooks/chat_scroll.dart | 131 ++++++++++++++++++++++++ lib/pages/chat.dart | 1 + lib/widgets/room_chat/room_chat.dart | 143 ++++++--------------------- 3 files changed, 160 insertions(+), 115 deletions(-) create mode 100644 lib/helpers/hooks/chat_scroll.dart diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart new file mode 100644 index 0000000..b0b39ca --- /dev/null +++ b/lib/helpers/hooks/chat_scroll.dart @@ -0,0 +1,131 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +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, + required final ScrollController scrollController, + required final bool hasMore, + required final Future Function() loadOlder, + required final Future Function(String id) jumpToId, +}) { + 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 scrollController = useScrollController(); + + final hasMore = useState(true); + final topItemBeforeLoad = useState(null); + final loadingOlder = useRef(false); + final initialized = useRef(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, + ); + } + + 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); + } + }); + } + + return null; + }, [controllerData]); + + useEffect(() { + Future listener() async { + if (!scrollController.hasClients || !scrollController.position.atEdge) { + return; + } + + if (scrollController.position.pixels == 0) { + if (shouldLoadOlder()) { + await loadOlderItems(); + } + } else { + await onReachedBottom(); + } + } + + scrollController.addListener(listener); + + return () => scrollController.removeListener(listener); + }, [controllerData]); + + return .new( + listController: listController.value, + scrollController: scrollController, + hasMore: hasMore.value, + loadOlder: loadOlderItems, + jumpToId: jumpToId, + ); + } +} diff --git a/lib/pages/chat.dart b/lib/pages/chat.dart index 5651b8d..279f077 100644 --- a/lib/pages/chat.dart +++ b/lib/pages/chat.dart @@ -27,6 +27,7 @@ class const ChatPage({super.key}) extends ConsumerWidget { if (isDesktop) Sidebar(isDesktop: isDesktop), Expanded( child: RoomChat( + key: ValueKey(roomId), roomId: roomId, isDesktop: isDesktop, showMembersByDefault: showMembersByDefault, diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index df3cf56..46d4d23 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -9,6 +9,7 @@ import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/member_list_opened.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/room_chat.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/composer.dart"; @@ -16,7 +17,6 @@ import "package:nexus/widgets/pinned_events_drawer.dart"; import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/room_appbar.dart"; import "package:nexus/main.dart"; -import "package:super_sliver_list/super_sliver_list.dart"; import "package:nexus/widgets/room_chat/chat_timeline.dart"; import "package:nexus/helpers/extensions/build_event_options.dart"; @@ -64,123 +64,27 @@ final class const RoomChat({ final client = ref.watch(ClientController.provider.notifier); - final listController = useRef(ListController()); - final scrollController = useScrollController(); final controllerData = ref.watch(controllerProvider); - final topEventBeforeLoad = useState(null); - final hasMore = useState(true); - final loadingOlder = useRef(false); - - Future jumpToId(String eventId) async { - final index = - controllerData.value?.indexWhere( - (element) => element.eventId == eventId, - ) ?? - -1; - if (index == -1) return; - - listController.value.animateToItem( - index: index, - scrollController: scrollController, - alignment: 0.5, - duration: (_) => .new(milliseconds: 700), - curve: (_) => Curves.easeInOut, - ); - highlightedEvent.value = eventId; - await Future.delayed(.new(seconds: 1), () { - if (highlightedEvent.value == eventId) { - highlightedEvent.value = null; - } - }); - } - - Future loadOlder() async { - if (loadingOlder.value || !hasMore.value) return; - if (controllerData case AsyncData(:final value?)) { - loadingOlder.value = true; - topEventBeforeLoad.value = value.firstOrNull?.eventId; - try { - hasMore.value = await notifier.loadOlder(); - } finally { - loadingOlder.value = false; - } - } - } - - 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 scroll = ChatScroll.use( + 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((value) => value[roomId]), + RoomsController.provider.select((rooms) => rooms[roomId]), ); - if (room == null) return; - if (scrollController.position.pixels == 0) { - if (room.hasMore) { - await loadOlder(); - } - } else { + if (room != null) { await client.markRead(room); } - } - - scrollController.addListener(listener); - return () => scrollController.removeListener(listener); - }, [roomId, controllerData]); + }, + ); final composerNode = useFocusNode( onKeyEvent: (_, event) { @@ -193,6 +97,15 @@ final class const RoomChat({ }, ); + Future jumpToId(String eventId) async { + highlightedEvent.value = eventId; + + await scroll.jumpToId(eventId); + await Future.delayed(.new(seconds: 1), () { + if (highlightedEvent.value == eventId) highlightedEvent.value = null; + }); + } + IList getEventOptions(Event event) => event.buildEventOptions( context: context, @@ -237,10 +150,10 @@ final class const RoomChat({ padding: .symmetric(horizontal: 4), child: ChatTimeline( controllerData: controllerData, - scrollController: scrollController, - listController: listController.value, - hasMore: hasMore.value, - loadOlder: loadOlder, + scrollController: scroll.scrollController, + listController: scroll.listController, + hasMore: scroll.hasMore, + loadOlder: scroll.loadOlder, jumpToId: jumpToId, getEventOptions: getEventOptions, highlightedEvent: highlightedEvent.value,