Compare commits

..
3 changed files with 49 additions and 128 deletions

View file

@ -1,5 +1,3 @@
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";
@ -12,7 +10,6 @@ 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,
@ -22,85 +19,30 @@ 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 anchorId = useState<String?>(null); final historyListController = useRef(ListController());
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 pendingAnchorTarget = useState<String?>(null); final anchorId = 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) {
final hasContextualEvent = value.timeline.any( anchorId.value = value.timeline.last.eventId;
(event) => event.eventId == contextualEvent,
);
anchorId.value = hasContextualEvent
? contextualEvent
: value.timeline.last.eventId;
} }
} }
return null; return null;
}, [controllerData, contextualEvent]); }, [controllerData]);
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 = anchorId.value; final anchor = anchorIdValue;
if (items == null || anchor == null) { if (items == null || anchor == null) {
return (history: const .empty(), live: const .empty()); return (history: const .empty(), live: const .empty());
@ -116,58 +58,62 @@ 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, anchorId.value]); }, [controllerData, anchorIdValue]);
useEffect( useEffect(() {
() { const topThreshold = 500.0;
const topThreshold = 500.0; const bottomThreshold = 50.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);
} }
final liveIndex = split.live.indexWhere((item) => item.eventId == itemId); scrollController.addListener(checkPosition);
if (liveIndex != -1) {
// ignore: invalid_use_of_visible_for_testing_member
return liveListController.getOffsetToReveal(liveIndex, 0.5);
}
return null; WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition());
}
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 offset = resolveOffset(itemId); 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
}
if (offset != null) { if (offset != null) {
await scrollController.animateTo( await scrollController.animateTo(
@ -175,23 +121,6 @@ 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,
);
} }
} }
@ -199,9 +128,8 @@ final class ChatScroll({
historyItems: split.history, historyItems: split.history,
liveItems: split.live, liveItems: split.live,
centerKey: centerKey, centerKey: centerKey,
anchorItemKey: anchorItemKey, historyListController: historyListController.value,
historyListController: historyListController, liveListController: liveListController.value,
liveListController: liveListController,
scrollController: scrollController, scrollController: scrollController,
jumpToId: jumpToId, jumpToId: jumpToId,
); );

View file

@ -28,7 +28,6 @@ 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,
@ -36,7 +35,7 @@ class const ChatTimeline({
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
isGrouped: isGrouped(event, previousEvent), isGrouped: isGrouped(event, previousEvent),
), ),
key: key, key: ValueKey(event.eventId),
isHighlighted: highlightedEvent == event.eventId, isHighlighted: highlightedEvent == event.eventId,
); );
@ -60,9 +59,6 @@ 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),
), ),
), ),
@ -76,7 +72,6 @@ class const ChatTimeline({
jumpToId: jumpToId, jumpToId: jumpToId,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
highlightedEvent: highlightedEvent, highlightedEvent: highlightedEvent,
key: ValueKey(scroll.historyItems[index].eventId),
), ),
), ),
], ],

View file

@ -74,8 +74,6 @@ 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]),