extract scroll helpers into their own hook

This commit is contained in:
Henry Hiles 2026-09-24 18:10:30 -04:00
commit eeb2e2f54b
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
3 changed files with 160 additions and 115 deletions

View file

@ -0,0 +1,131 @@
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:super_sliver_list/super_sliver_list.dart";
final class ChatScroll({
required final ListController listController,
required final ScrollController scrollController,
required final bool hasMore,
required final Future<void> Function() loadOlder,
required final Future<void> Function(String id) jumpToId,
}) {
static ChatScroll 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 scrollController = useScrollController();
final hasMore = useState(true);
final topItemBeforeLoad = useState<String?>(null);
final loadingOlder = useRef(false);
final initialized = useRef(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,
);
}
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);
}
});
}
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);
return () => scrollController.removeListener(listener);
}, [controllerData]);
return .new(
listController: listController.value,
scrollController: scrollController,
hasMore: hasMore.value,
loadOlder: loadOlderItems,
jumpToId: jumpToId,
);
}
}

View file

@ -27,6 +27,7 @@ class const ChatPage({super.key}) extends ConsumerWidget {
if (isDesktop) Sidebar(isDesktop: isDesktop), if (isDesktop) Sidebar(isDesktop: isDesktop),
Expanded( Expanded(
child: RoomChat( child: RoomChat(
key: ValueKey(roomId),
roomId: roomId, roomId: roomId,
isDesktop: isDesktop, isDesktop: isDesktop,
showMembersByDefault: showMembersByDefault, showMembersByDefault: showMembersByDefault,

View file

@ -9,6 +9,7 @@ import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/member_list_opened.dart"; import "package:nexus/controllers/member_list_opened.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/room_chat.dart"; import "package:nexus/controllers/room_chat.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/composer.dart"; import "package:nexus/widgets/composer/composer.dart";
@ -16,7 +17,6 @@ import "package:nexus/widgets/pinned_events_drawer.dart";
import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart"; import "package:nexus/widgets/room_appbar.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:super_sliver_list/super_sliver_list.dart";
import "package:nexus/widgets/room_chat/chat_timeline.dart"; import "package:nexus/widgets/room_chat/chat_timeline.dart";
import "package:nexus/helpers/extensions/build_event_options.dart"; import "package:nexus/helpers/extensions/build_event_options.dart";
@ -64,123 +64,27 @@ final class const RoomChat({
final client = ref.watch(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
final listController = useRef(ListController());
final scrollController = useScrollController();
final controllerData = ref.watch(controllerProvider); final controllerData = ref.watch(controllerProvider);
final topEventBeforeLoad = useState<String?>(null); final scroll = ChatScroll.use(
final hasMore = useState<bool>(true); controllerData: controllerData,
final loadingOlder = useRef(false); id: (event) => event.eventId,
loadOlder: notifier.loadOlder,
Future<void> jumpToId(String eventId) async { shouldLoadOlder: () => ref.read(
final index = RoomsController.provider.select(
controllerData.value?.indexWhere( (rooms) => rooms[roomId]?.hasMore ?? false,
(element) => element.eventId == eventId, ),
) ?? ),
-1; onReachedBottom: () async {
if (index == -1) return;
listController.value.animateToItem(
index: index,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => .new(milliseconds: 700),
curve: (_) => Curves.easeInOut,
);
highlightedEvent.value = eventId;
await Future.delayed(.new(seconds: 1), () {
if (highlightedEvent.value == eventId) {
highlightedEvent.value = null;
}
});
}
Future<void> loadOlder() async {
if (loadingOlder.value || !hasMore.value) return;
if (controllerData case AsyncData(:final value?)) {
loadingOlder.value = true;
topEventBeforeLoad.value = value.firstOrNull?.eventId;
try {
hasMore.value = await notifier.loadOlder();
} finally {
loadingOlder.value = false;
}
}
}
useEffect(() {
ref
.read(controllerProvider.future)
.then(
(_) => WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.jumpTo(
scrollController.position.maxScrollExtent - .000001,
);
}
}),
);
return null;
}, [scrollController.hasClients]);
useEffect(() {
if (controllerData case AsyncData(:final value?)
when scrollController.hasClients) {
if (topEventBeforeLoad.value != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
final index = value.indexWhere(
(event) => event.eventId == topEventBeforeLoad.value,
);
if (index != -1) {
listController.value.jumpToItem(
index: index,
scrollController: scrollController,
alignment: 0,
);
}
}
topEventBeforeLoad.value = null;
});
} else if (scrollController.position.atEdge &&
scrollController.position.pixels != 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.jumpTo(
scrollController.position.maxScrollExtent,
);
}
});
}
}
return null;
}, [controllerData]);
useEffect(() {
Future<void> listener() async {
if (!scrollController.hasClients || !scrollController.position.atEdge) {
return;
}
final room = ref.read( final room = ref.read(
RoomsController.provider.select((value) => value[roomId]), RoomsController.provider.select((rooms) => rooms[roomId]),
); );
if (room == null) return;
if (scrollController.position.pixels == 0) { if (room != null) {
if (room.hasMore) {
await loadOlder();
}
} else {
await client.markRead(room); await client.markRead(room);
} }
} },
);
scrollController.addListener(listener);
return () => scrollController.removeListener(listener);
}, [roomId, controllerData]);
final composerNode = useFocusNode( final composerNode = useFocusNode(
onKeyEvent: (_, event) { onKeyEvent: (_, event) {
@ -193,6 +97,15 @@ final class const RoomChat({
}, },
); );
Future<void> jumpToId(String eventId) async {
highlightedEvent.value = eventId;
await scroll.jumpToId(eventId);
await Future.delayed(.new(seconds: 1), () {
if (highlightedEvent.value == eventId) highlightedEvent.value = null;
});
}
IList<PopupMenuEntry> getEventOptions(Event event) => IList<PopupMenuEntry> getEventOptions(Event event) =>
event.buildEventOptions( event.buildEventOptions(
context: context, context: context,
@ -237,10 +150,10 @@ final class const RoomChat({
padding: .symmetric(horizontal: 4), padding: .symmetric(horizontal: 4),
child: ChatTimeline( child: ChatTimeline(
controllerData: controllerData, controllerData: controllerData,
scrollController: scrollController, scrollController: scroll.scrollController,
listController: listController.value, listController: scroll.listController,
hasMore: hasMore.value, hasMore: scroll.hasMore,
loadOlder: loadOlder, loadOlder: scroll.loadOlder,
jumpToId: jumpToId, jumpToId: jumpToId,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
highlightedEvent: highlightedEvent.value, highlightedEvent: highlightedEvent.value,