diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index 065a215..c2d047d 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; @@ -10,6 +12,7 @@ final class ChatScroll({ required final IList historyItems, required final IList liveItems, required final GlobalKey centerKey, + required final GlobalKey anchorItemKey, required final ListController historyListController, required final ListController liveListController, required final ScrollController scrollController, @@ -19,30 +22,64 @@ final class ChatScroll({ required AsyncValue controllerData, required Future Function() loadOlder, required Future Function() markRead, + required String? contextualEvent, + required void Function(String id) setContextualEvent, }) { - final historyListController = useRef(ListController()); - final liveListController = useRef(ListController()); + final anchorId = useState(null); + + final anchorItemKey = useMemoized(GlobalKey.new, [anchorId.value]); + final anchorItemKeyRef = useRef(anchorItemKey); + anchorItemKeyRef.value = anchorItemKey; + + final historyListController = useMemoized(ListController.new, [ + anchorId.value, + ]); + final liveListController = useMemoized(ListController.new, [ + anchorId.value, + ]); final scrollController = useScrollController(); final centerKey = useMemoized(GlobalKey.new); - final anchorId = useState(null); - - final anchorIdValue = anchorId.value; + final pendingAnchorTarget = useState(null); useEffect(() { if (anchorId.value == null) { if (controllerData case AsyncData(:final value?) when value.timeline.isNotEmpty) { - anchorId.value = value.timeline.last.eventId; + final hasContextualEvent = value.timeline.any( + (event) => event.eventId == contextualEvent, + ); + + anchorId.value = hasContextualEvent + ? contextualEvent + : value.timeline.last.eventId; } } return null; - }, [controllerData]); + }, [controllerData, contextualEvent]); + + useEffect(() { + final target = pendingAnchorTarget.value; + if (target == null) return null; + + final found = + controllerData.value?.timeline.any( + (event) => event.eventId == target, + ) ?? + false; + + if (found || controllerData is AsyncError) { + if (found) anchorId.value = target; + pendingAnchorTarget.value = null; + } + + return null; + }, [controllerData, pendingAnchorTarget.value]); final ({IList history, IList live}) split = useMemoized(() { final items = controllerData.value?.timeline; - final anchor = anchorIdValue; + final anchor = anchorId.value; if (items == null || anchor == null) { return (history: const .empty(), live: const .empty()); @@ -58,62 +95,58 @@ final class ChatScroll({ history: items.take(anchorIndex).toIList().reversed.toIList(), live: items.skip(anchorIndex).toIList(), ); - }, [controllerData, anchorIdValue]); + }, [controllerData, anchorId.value]); - useEffect(() { - const topThreshold = 500.0; - const bottomThreshold = 50.0; + useEffect( + () { + const topThreshold = 500.0; + const bottomThreshold = 50.0; - Future checkPosition() async { - if (!scrollController.hasClients) return; + Future checkPosition() async { + if (!scrollController.hasClients) return; + if (contextualEvent != null) return; - final position = scrollController.position; + final position = scrollController.position; - if (position.extentAfter <= topThreshold) { - await loadOlder(); - } else if (position.extentBefore <= bottomThreshold) { - await markRead(); + if (position.extentAfter <= topThreshold) { + await loadOlder(); + } else if (position.extentBefore <= bottomThreshold) { + await markRead(); + } } + + scrollController.addListener(checkPosition); + + WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition()); + + return () => scrollController.removeListener(checkPosition); + }, + [scrollController, controllerData, loadOlder, markRead, contextualEvent], + ); + + double? resolveOffset(String itemId) { + final historyIndex = split.history.indexWhere( + (item) => item.eventId == itemId, + ); + if (historyIndex != -1) { + // TODO: Replace SuperSliverView because of the bug that requires this: #94 + // ignore: invalid_use_of_visible_for_testing_member + return historyListController.getOffsetToReveal(historyIndex, 0.5); } - scrollController.addListener(checkPosition); + final liveIndex = split.live.indexWhere((item) => item.eventId == itemId); + if (liveIndex != -1) { + // ignore: invalid_use_of_visible_for_testing_member + return liveListController.getOffsetToReveal(liveIndex, 0.5); + } - WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition()); - - return () { - scrollController.removeListener(checkPosition); - }; - }, [scrollController, controllerData, loadOlder, markRead]); + return null; + } Future jumpToId(String itemId) async { if (!scrollController.hasClients) return; - final historyIndex = split.history.indexWhere( - (item) => item.eventId == itemId, - ); - - double? offset; - if (historyIndex != -1) { - // TODO: Replace SuperSliverView because of the bug that requires this: #94 - // ignore: invalid_use_of_visible_for_testing_member - offset = historyListController.value.getOffsetToReveal( - historyIndex, - 0.5, - ); - } else { - final liveIndex = split.live.indexWhere( - (item) => item.eventId == itemId, - ); - - if (liveIndex != -1) { - // ignore: invalid_use_of_visible_for_testing_member - offset = liveListController.value.getOffsetToReveal(liveIndex, 0.5); - } - } - - if (offset == null) { - // not in current timeline - } + final offset = resolveOffset(itemId); if (offset != null) { await scrollController.animateTo( @@ -121,6 +154,27 @@ final class ChatScroll({ duration: const Duration(milliseconds: 700), curve: Curves.easeInOut, ); + return; + } + + pendingAnchorTarget.value = itemId; + setContextualEvent(itemId); + + for (var i = 0; i < 60; i++) { + if (anchorId.value == itemId) { + final context = anchorItemKeyRef.value.currentContext; + if (context != null && context.mounted) { + await Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 700), + curve: Curves.easeInOut, + ); + return; + } + } + + await Future.delayed(const Duration(milliseconds: 16)); } } @@ -128,8 +182,9 @@ final class ChatScroll({ historyItems: split.history, liveItems: split.live, centerKey: centerKey, - historyListController: historyListController.value, - liveListController: liveListController.value, + anchorItemKey: anchorItemKey, + historyListController: historyListController, + liveListController: liveListController, scrollController: scrollController, jumpToId: jumpToId, ); diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index 3bc601c..77164c4 100644 --- a/lib/widgets/room_chat/chat_timeline.dart +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -28,6 +28,7 @@ class const ChatTimeline({ required Future Function(String) jumpToId, required IList Function(Event) getEventOptions, required String? highlightedEvent, + required Key key, }) => HighlightWrapper( EventRenderer( event, @@ -35,7 +36,7 @@ class const ChatTimeline({ getEventOptions: getEventOptions, isGrouped: isGrouped(event, previousEvent), ), - key: ValueKey(event.eventId), + key: key, isHighlighted: highlightedEvent == event.eventId, ); @@ -59,6 +60,9 @@ class const ChatTimeline({ jumpToId: jumpToId, getEventOptions: getEventOptions, highlightedEvent: highlightedEvent, + key: index == 0 + ? scroll.anchorItemKey + : ValueKey(scroll.liveItems[index].eventId), ), ), @@ -72,6 +76,7 @@ class const ChatTimeline({ jumpToId: jumpToId, getEventOptions: getEventOptions, highlightedEvent: highlightedEvent, + key: ValueKey(scroll.historyItems[index].eventId), ), ), ], diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 2aee4a5..be0a606 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -74,6 +74,8 @@ final class const RoomChat({ final scroll = ChatScroll.use( controllerData: controllerData, loadOlder: notifier.loadOlder, + contextualEvent: contextualEvent.value, + setContextualEvent: (id) => contextualEvent.value = id, markRead: () async { final room = ref.read( RoomsController.provider.select((rooms) => rooms[roomId]),