add support for jumping to out-of-context events (#95)
Fixes #84. Reviewed-on: #95
This commit is contained in:
parent
3429a3ed98
commit
389b65ae61
16 changed files with 463 additions and 132 deletions
|
|
@ -27,7 +27,9 @@ extension BuildEventOptions on Event {
|
|||
final theme = Theme.of(context);
|
||||
final danger = theme.colorScheme.error;
|
||||
|
||||
final notifier = ref.read(RoomChatController.provider(roomId).notifier);
|
||||
final notifier = ref.read(
|
||||
RoomChatController.provider((roomId, null)).notifier,
|
||||
);
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
||||
final isPinned = ref
|
||||
|
|
|
|||
|
|
@ -1,53 +1,112 @@
|
|||
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";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/models/direction.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/room_chat.dart";
|
||||
import "package:super_sliver_list/super_sliver_list.dart";
|
||||
|
||||
final class ChatScroll<T>({
|
||||
required final IList<T> historyItems,
|
||||
required final IList<T> liveItems,
|
||||
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,
|
||||
required final bool atBottom,
|
||||
required final Future<void> Function(String id) jumpToId,
|
||||
required final Future<void> Function() jumpToBottom,
|
||||
}) {
|
||||
static ChatScroll<T> use<T>({
|
||||
required AsyncValue<IList<T>?> controllerData,
|
||||
required String Function(T item) id,
|
||||
required Future<void> Function() loadOlder,
|
||||
required Future<void> Function() onReachedBottom,
|
||||
factory use({
|
||||
required AsyncValue<RoomChat?> controllerData,
|
||||
required Future<void> Function(Direction direction) paginate,
|
||||
required Future<void> Function() markRead,
|
||||
required ValueNotifier<String?> contextualEvent,
|
||||
}) {
|
||||
final historyListController = useRef(ListController());
|
||||
final liveListController = useRef(ListController());
|
||||
final anchorId = useState<String?>(null);
|
||||
|
||||
final anchorItemKey = useMemoized(GlobalKey.new, [anchorId.value]);
|
||||
|
||||
final historyListController = useMemoized(ListController.new);
|
||||
final liveListController = useMemoized(ListController.new);
|
||||
final scrollController = useScrollController();
|
||||
final centerKey = useMemoized(GlobalKey.new);
|
||||
|
||||
final anchorId = useState<String?>(null);
|
||||
final atBottom = useState(true);
|
||||
|
||||
final anchorIdValue = anchorId.value;
|
||||
final pendingAnchorTarget = useState<String?>(null);
|
||||
final anchorMountedCompleter = useRef<Completer<BuildContext>?>(null);
|
||||
|
||||
useEffect(() {
|
||||
if (anchorId.value == null) {
|
||||
if (controllerData case AsyncData(:final value?)
|
||||
when value.isNotEmpty) {
|
||||
anchorId.value = id(value.last);
|
||||
when value.timeline.isNotEmpty) {
|
||||
final hasContextualEvent = value.timeline.any(
|
||||
(event) => event.eventId == contextualEvent.value,
|
||||
);
|
||||
|
||||
anchorId.value = hasContextualEvent
|
||||
? contextualEvent.value
|
||||
: value.timeline.last.eventId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [controllerData]);
|
||||
}, [controllerData, contextualEvent.value]);
|
||||
|
||||
final ({IList<T> history, IList<T> live}) split = useMemoized(() {
|
||||
final items = controllerData.value;
|
||||
final anchor = anchorIdValue;
|
||||
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 items = controllerData.value?.timeline;
|
||||
final anchor = anchorId.value;
|
||||
|
||||
if (items == null || anchor == null) {
|
||||
return (history: const .empty(), live: const .empty());
|
||||
}
|
||||
|
||||
final anchorIndex = items.indexWhere((item) => id(item) == anchor);
|
||||
final anchorIndex = items.indexWhere((item) => item.eventId == anchor);
|
||||
|
||||
if (anchorIndex == -1) {
|
||||
return (history: const .empty(), live: items);
|
||||
|
|
@ -57,80 +116,122 @@ final class ChatScroll<T>({
|
|||
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 loadThreshold = 500.0;
|
||||
const readThreshold = 50.0;
|
||||
|
||||
Future<void> checkPosition() async {
|
||||
if (!scrollController.hasClients) return;
|
||||
Future<void> checkPosition() async {
|
||||
if (!scrollController.hasClients) return;
|
||||
|
||||
final position = scrollController.position;
|
||||
final position = scrollController.position;
|
||||
|
||||
if (position.extentAfter <= topThreshold) {
|
||||
await loadOlder();
|
||||
} else if (position.extentBefore <= bottomThreshold) {
|
||||
await onReachedBottom();
|
||||
final isAtBottom = position.extentBefore <= readThreshold;
|
||||
if (isAtBottom != atBottom.value) atBottom.value = isAtBottom;
|
||||
|
||||
if (position.extentAfter <= loadThreshold) {
|
||||
await paginate(.backward);
|
||||
} else if (contextualEvent.value != null &&
|
||||
position.extentBefore <= loadThreshold) {
|
||||
await paginate(.forward);
|
||||
} else if (position.extentBefore <= readThreshold) {
|
||||
await markRead();
|
||||
}
|
||||
}
|
||||
|
||||
scrollController.addListener(checkPosition);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition());
|
||||
|
||||
return () => scrollController.removeListener(checkPosition);
|
||||
},
|
||||
[
|
||||
scrollController,
|
||||
controllerData,
|
||||
paginate,
|
||||
markRead,
|
||||
contextualEvent.value,
|
||||
],
|
||||
);
|
||||
|
||||
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, onReachedBottom]);
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> jumpToId(String itemId) async {
|
||||
if (!scrollController.hasClients) return;
|
||||
|
||||
final historyIndex = split.history.indexWhere(
|
||||
(item) => id(item) == itemId,
|
||||
);
|
||||
|
||||
if (historyIndex != -1) {
|
||||
// TODO: Replace SuperSliverView because of the bug that requires this: #94
|
||||
// ignore: invalid_use_of_visible_for_testing_member
|
||||
final offset = historyListController.value.getOffsetToReveal(
|
||||
historyIndex,
|
||||
0.5,
|
||||
);
|
||||
final offset = resolveOffset(itemId);
|
||||
|
||||
if (offset != null) {
|
||||
await scrollController.animateTo(
|
||||
offset,
|
||||
duration: const Duration(milliseconds: 700),
|
||||
duration: const .new(milliseconds: 700),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} else {
|
||||
final liveIndex = split.live.indexWhere((item) => id(item) == itemId);
|
||||
|
||||
if (liveIndex != -1) {
|
||||
// ignore: invalid_use_of_visible_for_testing_member
|
||||
final offset = liveListController.value.getOffsetToReveal(
|
||||
liveIndex,
|
||||
0.5,
|
||||
);
|
||||
|
||||
await scrollController.animateTo(
|
||||
offset,
|
||||
duration: const Duration(milliseconds: 700),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final completer = Completer<BuildContext>();
|
||||
anchorMountedCompleter.value = completer;
|
||||
pendingAnchorTarget.value = itemId;
|
||||
contextualEvent.value = itemId;
|
||||
|
||||
final context = await completer.future;
|
||||
|
||||
if (context.mounted) {
|
||||
await Scrollable.ensureVisible(
|
||||
context,
|
||||
alignment: 0.5,
|
||||
duration: const .new(milliseconds: 700),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> jumpToBottom() async {
|
||||
if (contextualEvent.value != null) {
|
||||
anchorId.value = null;
|
||||
contextualEvent.value = null;
|
||||
}
|
||||
|
||||
if (!scrollController.hasClients) return;
|
||||
|
||||
await scrollController.animateTo(
|
||||
scrollController.position.minScrollExtent,
|
||||
duration: const .new(milliseconds: 700),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
return .new(
|
||||
historyItems: split.history,
|
||||
liveItems: split.live,
|
||||
centerKey: centerKey,
|
||||
historyListController: historyListController.value,
|
||||
liveListController: liveListController.value,
|
||||
anchorItemKey: anchorItemKey,
|
||||
historyListController: historyListController,
|
||||
liveListController: liveListController,
|
||||
scrollController: scrollController,
|
||||
atBottom: atBottom.value,
|
||||
jumpToId: jumpToId,
|
||||
jumpToBottom: jumpToBottom,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue