working jumps with scroll / highlight

This commit is contained in:
Henry Hiles 2026-09-25 21:21:39 -04:00
commit 3461fc5012
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
3 changed files with 111 additions and 49 deletions

View file

@ -1,3 +1,5 @@
import "dart:async";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
@ -10,6 +12,7 @@ final class ChatScroll({
required final IList<Event> historyItems, required final IList<Event> historyItems,
required final IList<Event> liveItems, required final IList<Event> liveItems,
required final GlobalKey centerKey, required final GlobalKey centerKey,
required final GlobalKey anchorItemKey,
required final ListController historyListController, required final ListController historyListController,
required final ListController liveListController, required final ListController liveListController,
required final ScrollController scrollController, required final ScrollController scrollController,
@ -19,30 +22,64 @@ final class ChatScroll({
required AsyncValue<RoomChat?> controllerData, required AsyncValue<RoomChat?> controllerData,
required Future<void> Function() loadOlder, required Future<void> Function() loadOlder,
required Future<void> Function() markRead, required Future<void> Function() markRead,
required String? contextualEvent,
required void Function(String id) setContextualEvent,
}) { }) {
final historyListController = useRef(ListController()); final anchorId = useState<String?>(null);
final liveListController = useRef(ListController());
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 scrollController = useScrollController();
final centerKey = useMemoized(GlobalKey.new); final centerKey = useMemoized(GlobalKey.new);
final anchorId = useState<String?>(null); final pendingAnchorTarget = useState<String?>(null);
final anchorIdValue = anchorId.value;
useEffect(() { useEffect(() {
if (anchorId.value == null) { if (anchorId.value == null) {
if (controllerData case AsyncData(:final value?) if (controllerData case AsyncData(:final value?)
when value.timeline.isNotEmpty) { 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; 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<Event> history, IList<Event> live}) split = useMemoized(() { final ({IList<Event> history, IList<Event> live}) split = useMemoized(() {
final items = controllerData.value?.timeline; final items = controllerData.value?.timeline;
final anchor = anchorIdValue; final anchor = anchorId.value;
if (items == null || anchor == null) { if (items == null || anchor == null) {
return (history: const .empty(), live: const .empty()); return (history: const .empty(), live: const .empty());
@ -58,62 +95,58 @@ final class ChatScroll({
history: items.take(anchorIndex).toIList().reversed.toIList(), history: items.take(anchorIndex).toIList().reversed.toIList(),
live: items.skip(anchorIndex).toIList(), live: items.skip(anchorIndex).toIList(),
); );
}, [controllerData, anchorIdValue]); }, [controllerData, anchorId.value]);
useEffect(() { useEffect(
const topThreshold = 500.0; () {
const bottomThreshold = 50.0; const topThreshold = 500.0;
const bottomThreshold = 50.0;
Future<void> checkPosition() async { Future<void> checkPosition() async {
if (!scrollController.hasClients) return; if (!scrollController.hasClients) return;
if (contextualEvent != null) return;
final position = scrollController.position; final position = scrollController.position;
if (position.extentAfter <= topThreshold) { if (position.extentAfter <= topThreshold) {
await loadOlder(); await loadOlder();
} else if (position.extentBefore <= bottomThreshold) { } else if (position.extentBefore <= bottomThreshold) {
await markRead(); 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 null;
}
return () {
scrollController.removeListener(checkPosition);
};
}, [scrollController, controllerData, loadOlder, markRead]);
Future<void> jumpToId(String itemId) async { Future<void> jumpToId(String itemId) async {
if (!scrollController.hasClients) return; if (!scrollController.hasClients) return;
final historyIndex = split.history.indexWhere( final offset = resolveOffset(itemId);
(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
}
if (offset != null) { if (offset != null) {
await scrollController.animateTo( await scrollController.animateTo(
@ -121,6 +154,27 @@ final class ChatScroll({
duration: const Duration(milliseconds: 700), duration: const Duration(milliseconds: 700),
curve: Curves.easeInOut, 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, historyItems: split.history,
liveItems: split.live, liveItems: split.live,
centerKey: centerKey, centerKey: centerKey,
historyListController: historyListController.value, anchorItemKey: anchorItemKey,
liveListController: liveListController.value, historyListController: historyListController,
liveListController: liveListController,
scrollController: scrollController, scrollController: scrollController,
jumpToId: jumpToId, jumpToId: jumpToId,
); );

View file

@ -28,6 +28,7 @@ class const ChatTimeline({
required Future<void> Function(String) jumpToId, required Future<void> Function(String) jumpToId,
required IList<PopupMenuEntry> Function(Event) getEventOptions, required IList<PopupMenuEntry> Function(Event) getEventOptions,
required String? highlightedEvent, required String? highlightedEvent,
required Key key,
}) => HighlightWrapper( }) => HighlightWrapper(
EventRenderer( EventRenderer(
event, event,
@ -35,7 +36,7 @@ class const ChatTimeline({
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
isGrouped: isGrouped(event, previousEvent), isGrouped: isGrouped(event, previousEvent),
), ),
key: ValueKey(event.eventId), key: key,
isHighlighted: highlightedEvent == event.eventId, isHighlighted: highlightedEvent == event.eventId,
); );
@ -59,6 +60,9 @@ class const ChatTimeline({
jumpToId: jumpToId, jumpToId: jumpToId,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
highlightedEvent: highlightedEvent, highlightedEvent: highlightedEvent,
key: index == 0
? scroll.anchorItemKey
: ValueKey(scroll.liveItems[index].eventId),
), ),
), ),
@ -72,6 +76,7 @@ class const ChatTimeline({
jumpToId: jumpToId, jumpToId: jumpToId,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
highlightedEvent: highlightedEvent, highlightedEvent: highlightedEvent,
key: ValueKey(scroll.historyItems[index].eventId),
), ),
), ),
], ],

View file

@ -74,6 +74,8 @@ final class const RoomChat({
final scroll = ChatScroll.use( final scroll = ChatScroll.use(
controllerData: controllerData, controllerData: controllerData,
loadOlder: notifier.loadOlder, loadOlder: notifier.loadOlder,
contextualEvent: contextualEvent.value,
setContextualEvent: (id) => contextualEvent.value = id,
markRead: () async { markRead: () async {
final room = ref.read( final room = ref.read(
RoomsController.provider.select((rooms) => rooms[roomId]), RoomsController.provider.select((rooms) => rooms[roomId]),