diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index d341645..e1fbf9f 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -17,14 +17,15 @@ final class ChatScroll({ required final ListController historyListController, required final ListController liveListController, required final ScrollController scrollController, + required final bool atBottom, required final Future Function(String id) jumpToId, + required final Future Function() jumpToBottom, }) { factory use({ required AsyncValue controllerData, required Future Function(Direction direction) paginate, required Future Function() markRead, - required String? contextualEvent, - required void Function(String id) setContextualEvent, + required ValueNotifier contextualEvent, }) { final anchorId = useState(null); @@ -35,6 +36,8 @@ final class ChatScroll({ final scrollController = useScrollController(); final centerKey = useMemoized(GlobalKey.new); + final atBottom = useState(true); + final pendingAnchorTarget = useState(null); final anchorMountedCompleter = useRef?>(null); @@ -43,17 +46,17 @@ final class ChatScroll({ if (controllerData case AsyncData(:final value?) when value.timeline.isNotEmpty) { final hasContextualEvent = value.timeline.any( - (event) => event.eventId == contextualEvent, + (event) => event.eventId == contextualEvent.value, ); anchorId.value = hasContextualEvent - ? contextualEvent + ? contextualEvent.value : value.timeline.last.eventId; } } return null; - }, [controllerData, contextualEvent]); + }, [controllerData, contextualEvent.value]); useEffect(() { final target = pendingAnchorTarget.value; @@ -115,31 +118,43 @@ final class ChatScroll({ ); }, [controllerData, anchorId.value]); - useEffect(() { - const loadThreshold = 500.0; - const readThreshold = 50.0; + useEffect( + () { + const loadThreshold = 500.0; + const readThreshold = 50.0; - Future checkPosition() async { - if (!scrollController.hasClients) return; + Future checkPosition() async { + if (!scrollController.hasClients) return; - final position = scrollController.position; + final position = scrollController.position; - if (position.extentAfter <= loadThreshold) { - await paginate(.backward); - } else if (contextualEvent != null && - position.extentBefore <= loadThreshold) { - await paginate(.forward); - } else if (position.extentBefore <= readThreshold) { - await markRead(); + final isAtBottom = position.extentBefore <= readThreshold; + if (isAtBottom != atBottom.value) atBottom.value = isAtBottom; + + if (position.extentAfter <= loadThreshold) { + await paginate(.backward); + } else if (contextualEvent.value != null && + position.extentBefore <= loadThreshold) { + await paginate(.forward); + } else if (position.extentBefore <= readThreshold) { + await markRead(); + } } - } - scrollController.addListener(checkPosition); + scrollController.addListener(checkPosition); - WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition()); + WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition()); - return () => scrollController.removeListener(checkPosition); - }, [scrollController, controllerData, paginate, markRead, contextualEvent]); + return () => scrollController.removeListener(checkPosition); + }, + [ + scrollController, + controllerData, + paginate, + markRead, + contextualEvent.value, + ], + ); double? resolveOffset(String itemId) { final historyIndex = split.history.indexWhere( @@ -177,7 +192,7 @@ final class ChatScroll({ final completer = Completer(); anchorMountedCompleter.value = completer; pendingAnchorTarget.value = itemId; - setContextualEvent(itemId); + contextualEvent.value = itemId; final context = await completer.future; @@ -191,6 +206,21 @@ final class ChatScroll({ } } + Future jumpToBottom() async { + if (contextualEvent.value != null) { + anchorId.value = null; + contextualEvent.value = null; + } + + if (!scrollController.hasClients) return; + + await scrollController.animateTo( + scrollController.position.minScrollExtent, + duration: const .new(milliseconds: 700), + curve: Curves.easeInOut, + ); + } + return .new( historyItems: split.history, liveItems: split.live, @@ -199,7 +229,9 @@ final class ChatScroll({ historyListController: historyListController, liveListController: liveListController, scrollController: scrollController, + atBottom: atBottom.value, jumpToId: jumpToId, + jumpToBottom: jumpToBottom, ); } } diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 7648e00..5f2e196 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -74,8 +74,7 @@ final class const RoomChat({ final scroll = ChatScroll.use( controllerData: controllerData, paginate: notifier.paginate, - contextualEvent: contextualEvent.value, - setContextualEvent: (id) => contextualEvent.value = id, + contextualEvent: contextualEvent, markRead: () async { final room = ref.read( RoomsController.provider.select((rooms) => rooms[roomId]), @@ -159,6 +158,21 @@ final class const RoomChat({ ), ), ), + Positioned( + right: 16, + bottom: composerSize.value, + child: IgnorePointer( + ignoring: scroll.atBottom, + child: AnimatedOpacity( + opacity: scroll.atBottom ? 0 : 1, + duration: const Duration(milliseconds: 200), + child: FloatingActionButton.small( + onPressed: scroll.jumpToBottom, + child: const Icon(Icons.keyboard_arrow_down), + ), + ), + ), + ), Positioned( bottom: 0, left: 0,