Compare commits

...
Author SHA1 Message Date
72321cc413
clean up approach to avoid polling 2026-09-25 21:26:01 -04:00
3461fc5012
working jumps with scroll / highlight 2026-09-25 21:21:39 -04:00
3 changed files with 128 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,85 @@ 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 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 anchorMountedCompleter = useRef<Completer<BuildContext>?>(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;
} else {
anchorMountedCompleter.value?.completeError(
StateError("Failed to load context for $target"),
);
anchorMountedCompleter.value = null;
}
pendingAnchorTarget.value = null;
}
return null;
}, [controllerData, pendingAnchorTarget.value]);
useEffect(() {
final completer = anchorMountedCompleter.value;
if (completer == null) return null;
WidgetsBinding.instance.addPostFrameCallback((_) {
final context = anchorItemKey.currentContext;
if (context != null && context.mounted) {
anchorMountedCompleter.value?.complete(context);
anchorMountedCompleter.value = null;
}
});
return null;
}, [anchorId.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 +116,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 +175,23 @@ final class ChatScroll({
duration: const Duration(milliseconds: 700), duration: const Duration(milliseconds: 700),
curve: Curves.easeInOut, curve: Curves.easeInOut,
); );
return;
}
final completer = Completer<BuildContext>();
anchorMountedCompleter.value = completer;
pendingAnchorTarget.value = itemId;
setContextualEvent(itemId);
final context = await completer.future;
if (context.mounted) {
await Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 700),
curve: Curves.easeInOut,
);
} }
} }
@ -128,8 +199,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]),