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) { if (!room.hasFetchedState) {
final state = await client.getRoomState(.new(roomId: roomId)); final state = await client.getRoomState(.new(roomId: roomId));
await ref.read(RoomsController.provider.notifier).addState(roomId, state); 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 return room.timeline
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
.map((element) => element.value) .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:material_ui/material_ui.dart";
import "package:super_sliver_list/super_sliver_list.dart"; import "package:super_sliver_list/super_sliver_list.dart";
final class ChatScroll({ final class ChatScroll<T>({
required final ListController listController, 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 ScrollController scrollController,
required final bool hasMore, required final bool hasMore,
required final bool isLoadingOlder,
required final Future<void> Function() loadOlder, required final Future<void> Function() loadOlder,
required final Future<void> Function(String id) jumpToId, required final Future<void> Function(String id) jumpToId,
}) { }) {
static ChatScroll use<T>({ static ChatScroll<T> use<T>({
required AsyncValue<IList<T>?> controllerData, required AsyncValue<IList<T>?> controllerData,
required String Function(T item) id, required String Function(T item) id,
required Future<bool> Function() loadOlder, required Future<bool> Function() loadOlder,
required bool Function() shouldLoadOlder,
required Future<void> Function() onReachedBottom, required Future<void> Function() onReachedBottom,
}) { }) {
final listController = useRef(ListController()); final historyListController = useRef(ListController());
final liveListController = useRef(ListController());
final scrollController = useScrollController(); final scrollController = useScrollController();
final centerKey = useMemoized(GlobalKey.new);
final anchorId = useState<String?>(null);
final hasMore = useState(true); final hasMore = useState(true);
final topItemBeforeLoad = useState<String?>(null); final isLoadingOlder = useState(false);
final loadingOlder = useRef(false);
final initialized = useRef(false); final anchorIdValue = anchorId.value;
useEffect(() {
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 { Future<void> loadOlderItems() async {
if (loadingOlder.value || !hasMore.value) return; if (!hasMore.value || isLoadingOlder.value) return;
if (controllerData case AsyncData(:final value?)) { isLoadingOlder.value = true;
loadingOlder.value = true;
topItemBeforeLoad.value = value.firstOrNull == null
? null
: id(value.first);
try { try {
hasMore.value = await loadOlder(); hasMore.value = await loadOlder();
} finally { } finally {
loadingOlder.value = false; isLoadingOlder.value = false;
}
} }
} }
Future<void> jumpToId(String itemId) async { Future<void> jumpToId(String itemId) async {
final index = if (!scrollController.hasClients) return;
controllerData.value?.indexWhere((item) => id(item) == itemId) ?? -1;
if (index == -1) return; final historyIndex = split.history.indexWhere(
(item) => id(item) == itemId,
);
listController.value.animateToItem( if (historyIndex != -1) {
index: index, 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, scrollController: scrollController,
alignment: 0.5, alignment: 0.5,
duration: (_) => .new(milliseconds: 700), duration: (_) => .new(milliseconds: 700),
curve: (_) => Curves.easeInOut, curve: (_) => Curves.easeInOut,
); );
} }
}
useEffect(() { useEffect(() {
if (controllerData case AsyncData(:final value?)) { const loadThreshold = 500.0;
WidgetsBinding.instance.addPostFrameCallback((_) { const bottomThreshold = 50.0;
if (!scrollController.hasClients) return;
if (!initialized.value) { void checkPosition() {
initialized.value = true; if (!scrollController.hasClients) {
if (value.isNotEmpty) {
listController.value.jumpToItem(
index: value.length - 1,
scrollController: scrollController,
alignment: 1,
);
}
return; return;
} }
final topItem = topItemBeforeLoad.value; final position = scrollController.position;
if (topItem != null) { if (position.extentAfter <= loadThreshold) {
final index = value.indexWhere((item) => id(item) == topItem); if (hasMore.value && !isLoadingOlder.value) {
loadOlderItems();
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);
}
});
}
return null;
}, [controllerData]);
useEffect(() {
Future<void> listener() async {
if (!scrollController.hasClients || !scrollController.position.atEdge) {
return;
}
if (scrollController.position.pixels == 0) {
if (shouldLoadOlder()) {
await loadOlderItems();
}
} else {
await onReachedBottom();
} }
} }
scrollController.addListener(listener); if (position.extentBefore <= bottomThreshold) {
onReachedBottom();
}
}
return () => scrollController.removeListener(listener); scrollController.addListener(checkPosition);
}, [controllerData]);
WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition());
return () => scrollController.removeListener(checkPosition);
}, [scrollController, onReachedBottom]);
return .new( return .new(
listController: listController.value, historyItems: split.history,
liveItems: split.live,
centerKey: centerKey,
historyListController: historyListController.value,
liveListController: liveListController.value,
scrollController: scrollController, scrollController: scrollController,
hasMore: hasMore.value, hasMore: hasMore.value,
isLoadingOlder: isLoadingOlder.value,
loadOlder: loadOlderItems, loadOlder: loadOlderItems,
jumpToId: jumpToId, jumpToId: jumpToId,
); );

View file

@ -1,76 +1,79 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.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/content/message.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/renderers/event.dart";
import "package:nexus/widgets/highlight_wrapper.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"; import "package:super_sliver_list/super_sliver_list.dart";
class const ChatTimeline({ class const ChatTimeline({
required final AsyncValue<IList<Event>?> controllerData, required final ChatScroll<Event> scroll,
required final ScrollController scrollController,
required final ListController listController,
required final bool hasMore,
required final Future<void> Function() loadOlder,
required final Future<void> Function(String) jumpToId, required final Future<void> Function(String) jumpToId,
required final IList<PopupMenuEntry> Function(Event) getEventOptions, required final IList<PopupMenuEntry> Function(Event) getEventOptions,
required final String? highlightedEvent, required final String? highlightedEvent,
required final double composerHeight, required final double composerHeight,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override bool isGrouped(Event event, Event? previousEvent) =>
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"),
),
),
),
),
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?.content is MessageContent &&
previousEvent?.redactedBy == null && previousEvent?.redactedBy == null &&
previousEvent?.relationType != "m.replace" && previousEvent?.relationType != "m.replace" &&
event.sender == previousEvent?.sender && event.sender == previousEvent?.sender &&
event.pmp?.id == previousEvent?.pmp?.id, 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, isHighlighted: highlightedEvent == event.eventId,
); );
},
@override
Widget build(BuildContext context) => CustomScrollView(
reverse: true,
center: scroll.centerKey,
keyboardDismissBehavior: .onDrag,
controller: scroll.scrollController,
slivers: [
SliverToBoxAdapter(child: SizedBox(height: composerHeight)),
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)), 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,
),
),
], ],
), );
AsyncData() => Center(child: Text("Nothing to see here...")),
AsyncLoading() => Loading(),
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
};
} }

View file

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