refactor chatview to have two sliver lists

Co-authored-by: Erwan Leboucher <erwanleboucher@gmail.com>
This commit is contained in:
Henry Hiles 2026-09-24 20:02:23 -04:00
commit 110caff170
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
4 changed files with 161 additions and 152 deletions

View file

@ -29,16 +29,9 @@ class RoomChatController(final String roomId)
if (!room.hasFetchedState) {
final state = await client.getRoomState(.new(roomId: roomId));
await ref.read(RoomsController.provider.notifier).addState(roomId, state);
}
// While there are under 20 events, try to load more
// until there's no more or the conditions are met.
if (room.hasMore && room.timeline.length < 20) {
loadOlder();
}
return room.timeline
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
.map((element) => element.value)

View file

@ -4,126 +4,148 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:super_sliver_list/super_sliver_list.dart";
final class ChatScroll({
required final ListController listController,
final class ChatScroll<T>({
required final IList<T> historyItems,
required final IList<T> liveItems,
required final GlobalKey centerKey,
required final ListController historyListController,
required final ListController liveListController,
required final ScrollController scrollController,
required final bool hasMore,
required final bool isLoadingOlder,
required final Future<void> Function() loadOlder,
required final Future<void> Function(String id) jumpToId,
}) {
static ChatScroll use<T>({
static ChatScroll<T> use<T>({
required AsyncValue<IList<T>?> controllerData,
required String Function(T item) id,
required Future<bool> Function() loadOlder,
required bool Function() shouldLoadOlder,
required Future<void> Function() onReachedBottom,
}) {
final listController = useRef(ListController());
final historyListController = useRef(ListController());
final liveListController = useRef(ListController());
final scrollController = useScrollController();
final centerKey = useMemoized(GlobalKey.new);
final anchorId = useState<String?>(null);
final hasMore = useState(true);
final topItemBeforeLoad = useState<String?>(null);
final loadingOlder = useRef(false);
final initialized = useRef(false);
final isLoadingOlder = useState(false);
Future<void> loadOlderItems() async {
if (loadingOlder.value || !hasMore.value) return;
if (controllerData case AsyncData(:final value?)) {
loadingOlder.value = true;
topItemBeforeLoad.value = value.firstOrNull == null
? null
: id(value.first);
try {
hasMore.value = await loadOlder();
} finally {
loadingOlder.value = false;
}
}
}
Future<void> jumpToId(String itemId) async {
final index =
controllerData.value?.indexWhere((item) => id(item) == itemId) ?? -1;
if (index == -1) return;
listController.value.animateToItem(
index: index,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => .new(milliseconds: 700),
curve: (_) => Curves.easeInOut,
);
}
final anchorIdValue = anchorId.value;
useEffect(() {
if (controllerData case AsyncData(:final value?)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!scrollController.hasClients) return;
if (!initialized.value) {
initialized.value = true;
if (value.isNotEmpty) {
listController.value.jumpToItem(
index: value.length - 1,
scrollController: scrollController,
alignment: 1,
);
}
return;
}
final topItem = topItemBeforeLoad.value;
if (topItem != null) {
final index = value.indexWhere((item) => id(item) == topItem);
if (index != -1) {
listController.value.jumpToItem(
index: index,
scrollController: scrollController,
alignment: 0,
);
}
topItemBeforeLoad.value = null;
} else if (scrollController.position.atEdge &&
scrollController.position.pixels != 0) {
scrollController.jumpTo(scrollController.position.maxScrollExtent);
}
});
if (anchorId.value == null) {
if (controllerData case AsyncData(:final value?)
when value.isNotEmpty) {
anchorId.value = id(value.last);
}
}
return null;
}, [controllerData]);
final ({IList<T> history, IList<T> live}) split = useMemoized(() {
final items = controllerData.value;
final anchor = anchorIdValue;
if (items == null || anchor == null) {
return (history: const .empty(), live: const .empty());
}
final anchorIndex = items.indexWhere((item) => id(item) == anchor);
if (anchorIndex == -1) {
return (history: const .empty(), live: items);
}
return (
history: items.take(anchorIndex).toIList().reversed.toIList(),
live: items.skip(anchorIndex).toIList(),
);
}, [controllerData, anchorIdValue]);
Future<void> loadOlderItems() async {
if (!hasMore.value || isLoadingOlder.value) return;
isLoadingOlder.value = true;
try {
hasMore.value = await loadOlder();
} finally {
isLoadingOlder.value = false;
}
}
Future<void> jumpToId(String itemId) async {
if (!scrollController.hasClients) return;
final historyIndex = split.history.indexWhere(
(item) => id(item) == itemId,
);
if (historyIndex != -1) {
historyListController.value.animateToItem(
index: historyIndex,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => .new(milliseconds: 700),
curve: (_) => Curves.easeInOut,
);
return;
}
final liveIndex = split.live.indexWhere((item) => id(item) == itemId);
if (liveIndex != -1) {
liveListController.value.animateToItem(
index: liveIndex,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => .new(milliseconds: 700),
curve: (_) => Curves.easeInOut,
);
}
}
useEffect(() {
Future<void> listener() async {
if (!scrollController.hasClients || !scrollController.position.atEdge) {
const loadThreshold = 500.0;
const bottomThreshold = 50.0;
void checkPosition() {
if (!scrollController.hasClients) {
return;
}
if (scrollController.position.pixels == 0) {
if (shouldLoadOlder()) {
await loadOlderItems();
final position = scrollController.position;
if (position.extentAfter <= loadThreshold) {
if (hasMore.value && !isLoadingOlder.value) {
loadOlderItems();
}
} else {
await onReachedBottom();
}
if (position.extentBefore <= bottomThreshold) {
onReachedBottom();
}
}
scrollController.addListener(listener);
scrollController.addListener(checkPosition);
return () => scrollController.removeListener(listener);
}, [controllerData]);
WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition());
return () => scrollController.removeListener(checkPosition);
}, [scrollController, onReachedBottom]);
return .new(
listController: listController.value,
historyItems: split.history,
liveItems: split.live,
centerKey: centerKey,
historyListController: historyListController.value,
liveListController: liveListController.value,
scrollController: scrollController,
hasMore: hasMore.value,
isLoadingOlder: isLoadingOlder.value,
loadOlder: loadOlderItems,
jumpToId: jumpToId,
);

View file

@ -1,76 +1,79 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/renderers/event.dart";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
import "package:super_sliver_list/super_sliver_list.dart";
class const ChatTimeline({
required final AsyncValue<IList<Event>?> controllerData,
required final ScrollController scrollController,
required final ListController listController,
required final bool hasMore,
required final Future<void> Function() loadOlder,
required final ChatScroll<Event> scroll,
required final Future<void> Function(String) jumpToId,
required final IList<PopupMenuEntry> Function(Event) getEventOptions,
required final String? highlightedEvent,
required final double composerHeight,
super.key,
}) extends StatelessWidget {
bool isGrouped(Event event, Event? previousEvent) =>
previousEvent?.content is MessageContent &&
previousEvent?.redactedBy == null &&
previousEvent?.relationType != "m.replace" &&
event.sender == previousEvent?.sender &&
event.pmp?.id == previousEvent?.pmp?.id;
Widget eventRow(
Event event,
Event? previousEvent, {
required Future<void> Function(String) jumpToId,
required IList<PopupMenuEntry> Function(Event) getEventOptions,
required String? highlightedEvent,
}) => HighlightWrapper(
EventRenderer(
event,
onTapReply: () => jumpToId(event.replyTo!),
getEventOptions: getEventOptions,
isGrouped: isGrouped(event, previousEvent),
),
key: ValueKey(event.eventId),
isHighlighted: highlightedEvent == event.eventId,
);
@override
Widget build(BuildContext context) => switch (controllerData) {
AsyncData(:final value?) || AsyncLoading(:final value?) => CustomScrollView(
keyboardDismissBehavior: .onDrag,
controller: scrollController,
slivers: [
if (hasMore)
SliverToBoxAdapter(
child: Padding(
padding: .symmetric(vertical: 36),
child: Center(
child: ElevatedButton(
onPressed: controllerData is AsyncData ? loadOlder : null,
child: Text("Load More"),
),
),
),
),
Widget build(BuildContext context) => CustomScrollView(
reverse: true,
center: scroll.centerKey,
keyboardDismissBehavior: .onDrag,
controller: scroll.scrollController,
slivers: [
SliverToBoxAdapter(child: SizedBox(height: composerHeight)),
SuperSliverList.builder(
listController: listController,
itemCount: value.length,
itemBuilder: (_, index) {
final event = value[index];
final previousEvent = value.getOrNull(index - 1);
return HighlightWrapper(
EventRenderer(
event,
onTapReply: () => jumpToId(event.replyTo!),
getEventOptions: getEventOptions,
isGrouped:
previousEvent?.content is MessageContent &&
previousEvent?.redactedBy == null &&
previousEvent?.relationType != "m.replace" &&
event.sender == previousEvent?.sender &&
event.pmp?.id == previousEvent?.pmp?.id,
),
isHighlighted: highlightedEvent == event.eventId,
);
},
SuperSliverList.builder(
listController: scroll.liveListController,
itemCount: scroll.liveItems.length,
itemBuilder: (_, index) => eventRow(
scroll.liveItems[index],
index > 0
? scroll.liveItems.getOrNull(index - 1)
: scroll.historyItems.firstOrNull,
jumpToId: jumpToId,
getEventOptions: getEventOptions,
highlightedEvent: highlightedEvent,
),
),
SliverPadding(padding: .only(bottom: composerHeight)),
],
),
AsyncData() => Center(child: Text("Nothing to see here...")),
AsyncLoading() => Loading(),
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
};
SuperSliverList.builder(
key: scroll.centerKey,
listController: scroll.historyListController,
itemCount: scroll.historyItems.length,
itemBuilder: (_, index) => eventRow(
scroll.historyItems[index],
scroll.historyItems.getOrNull(index),
jumpToId: jumpToId,
getEventOptions: getEventOptions,
highlightedEvent: highlightedEvent,
),
),
],
);
}

View file

@ -70,11 +70,6 @@ final class const RoomChat({
controllerData: controllerData,
id: (event) => event.eventId,
loadOlder: notifier.loadOlder,
shouldLoadOlder: () => ref.read(
RoomsController.provider.select(
(rooms) => rooms[roomId]?.hasMore ?? false,
),
),
onReachedBottom: () async {
final room = ref.read(
RoomsController.provider.select((rooms) => rooms[roomId]),
@ -149,11 +144,7 @@ final class const RoomChat({
child: Padding(
padding: .symmetric(horizontal: 4),
child: ChatTimeline(
controllerData: controllerData,
scrollController: scroll.scrollController,
listController: scroll.listController,
hasMore: scroll.hasMore,
loadOlder: scroll.loadOlder,
scroll: scroll,
jumpToId: jumpToId,
getEventOptions: getEventOptions,
highlightedEvent: highlightedEvent.value,