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:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
@ -10,6 +12,7 @@ final class ChatScroll({
required final IList<Event> historyItems,
required final IList<Event> 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<RoomChat?> controllerData,
required Future<void> Function() loadOlder,
required Future<void> Function() markRead,
required String? contextualEvent,
required void Function(String id) setContextualEvent,
}) {
final historyListController = useRef(ListController());
final liveListController = useRef(ListController());
final anchorId = useState<String?>(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<String?>(null);
final anchorIdValue = anchorId.value;
final pendingAnchorTarget = useState<String?>(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<Event> history, IList<Event> 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<void> checkPosition() async {
if (!scrollController.hasClients) return;
Future<void> 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<void> 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,
);

View file

@ -28,6 +28,7 @@ class const ChatTimeline({
required Future<void> Function(String) jumpToId,
required IList<PopupMenuEntry> 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),
),
),
],

View file

@ -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]),