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);
Future<void> loadOlderItems() async { final anchorIdValue = anchorId.value;
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,
);
}
useEffect(() { useEffect(() {
if (controllerData case AsyncData(:final value?)) { if (anchorId.value == null) {
WidgetsBinding.instance.addPostFrameCallback((_) { if (controllerData case AsyncData(:final value?)
if (!scrollController.hasClients) return; when value.isNotEmpty) {
anchorId.value = id(value.last);
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);
}
});
} }
return null; return null;
}, [controllerData]); }, [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(() { useEffect(() {
Future<void> listener() async { const loadThreshold = 500.0;
if (!scrollController.hasClients || !scrollController.position.atEdge) { const bottomThreshold = 50.0;
void checkPosition() {
if (!scrollController.hasClients) {
return; return;
} }
if (scrollController.position.pixels == 0) { final position = scrollController.position;
if (shouldLoadOlder()) {
await loadOlderItems(); 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); WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition());
}, [controllerData]);
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 {
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 @override
Widget build(BuildContext context) => switch (controllerData) { Widget build(BuildContext context) => CustomScrollView(
AsyncData(:final value?) || AsyncLoading(:final value?) => CustomScrollView( reverse: true,
keyboardDismissBehavior: .onDrag, center: scroll.centerKey,
controller: scrollController, keyboardDismissBehavior: .onDrag,
slivers: [ controller: scroll.scrollController,
if (hasMore) slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(child: SizedBox(height: composerHeight)),
child: Padding(
padding: .symmetric(vertical: 36),
child: Center(
child: ElevatedButton(
onPressed: controllerData is AsyncData ? loadOlder : null,
child: Text("Load More"),
),
),
),
),
SuperSliverList.builder( SuperSliverList.builder(
listController: listController, listController: scroll.liveListController,
itemCount: value.length, itemCount: scroll.liveItems.length,
itemBuilder: (_, index) { itemBuilder: (_, index) => eventRow(
final event = value[index]; scroll.liveItems[index],
final previousEvent = value.getOrNull(index - 1); index > 0
return HighlightWrapper( ? scroll.liveItems.getOrNull(index - 1)
EventRenderer( : scroll.historyItems.firstOrNull,
event, jumpToId: jumpToId,
onTapReply: () => jumpToId(event.replyTo!), getEventOptions: getEventOptions,
getEventOptions: getEventOptions, highlightedEvent: highlightedEvent,
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,
);
},
), ),
),
SliverPadding(padding: .only(bottom: composerHeight)), SuperSliverList.builder(
], key: scroll.centerKey,
), listController: scroll.historyListController,
AsyncData() => Center(child: Text("Nothing to see here...")), itemCount: scroll.historyItems.length,
AsyncLoading() => Loading(), itemBuilder: (_, index) => eventRow(
AsyncError(:final error, :final stackTrace) => ErrorDialog( scroll.historyItems[index],
error, scroll.historyItems.getOrNull(index),
stackTrace, jumpToId: jumpToId,
), getEventOptions: getEventOptions,
}; highlightedEvent: highlightedEvent,
),
),
],
);
} }

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,