refactor for RoomChatController to return a RoomChat

Will help with historical timeline support.
This commit is contained in:
Henry Hiles 2026-09-25 12:03:15 -04:00
commit 08e60a87df
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
5 changed files with 82 additions and 46 deletions

View file

@ -15,11 +15,11 @@ import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/room_chat.dart";
class RoomChatController(final String roomId) class RoomChatController(final String roomId) extends AsyncNotifier<RoomChat?> {
extends AsyncNotifier<IList<Event>?> {
@override @override
Future<IList<Event>?> build() async { Future<RoomChat?> build() async {
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
final room = ref.watch( final room = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]), RoomsController.provider.select((rooms) => rooms[roomId]),
@ -32,7 +32,8 @@ class RoomChatController(final String roomId)
await ref.read(RoomsController.provider.notifier).addState(roomId, state); await ref.read(RoomsController.provider.notifier).addState(roomId, state);
} }
return room.timeline return .new(
events: 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)
.toIList() .toIList()
@ -53,7 +54,9 @@ class RoomChatController(final String roomId)
); );
}) })
.nonNulls .nonNulls
.toIList(); .toIList(),
hasMore: room.hasMore,
);
} }
Future<void> deleteMessage(Event event, {String? reason}) => ref Future<void> deleteMessage(Event event, {String? reason}) => ref
@ -67,7 +70,7 @@ class RoomChatController(final String roomId)
); );
Future<void> loadOlder() async { Future<void> loadOlder() async {
if (state.isLoading) return; if (state.isLoading || state.value?.hasMore == false) return;
state = .loading(); state = .loading();
@ -224,7 +227,7 @@ class RoomChatController(final String roomId)
} }
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, IList<Event>?, String>( .autoDispose<RoomChatController, RoomChat?, String>(
RoomChatController.new, RoomChatController.new,
); );
} }

View file

@ -2,22 +2,23 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/room_chat.dart";
import "package:super_sliver_list/super_sliver_list.dart"; import "package:super_sliver_list/super_sliver_list.dart";
final class ChatScroll<T>({ final class ChatScroll({
required final IList<T> historyItems, required final IList<Event> historyItems,
required final IList<T> liveItems, required final IList<Event> liveItems,
required final GlobalKey centerKey, required final GlobalKey centerKey,
required final ListController historyListController, required final ListController historyListController,
required final ListController liveListController, required final ListController liveListController,
required final ScrollController scrollController, required final ScrollController scrollController,
required final Future<void> Function(String id) jumpToId, required final Future<void> Function(String id) jumpToId,
}) { }) {
static ChatScroll<T> use<T>({ static ChatScroll use({
required AsyncValue<IList<T>?> controllerData, required AsyncValue<RoomChat?> controllerData,
required String Function(T item) id,
required Future<void> Function() loadOlder, required Future<void> Function() loadOlder,
required Future<void> Function() onReachedBottom, required Future<void> Function() markRead,
}) { }) {
final historyListController = useRef(ListController()); final historyListController = useRef(ListController());
final liveListController = useRef(ListController()); final liveListController = useRef(ListController());
@ -31,23 +32,23 @@ final class ChatScroll<T>({
useEffect(() { useEffect(() {
if (anchorId.value == null) { if (anchorId.value == null) {
if (controllerData case AsyncData(:final value?) if (controllerData case AsyncData(:final value?)
when value.isNotEmpty) { when value.events.isNotEmpty) {
anchorId.value = id(value.last); anchorId.value = value.events.last.eventId;
} }
} }
return null; return null;
}, [controllerData]); }, [controllerData]);
final ({IList<T> history, IList<T> live}) split = useMemoized(() { final ({IList<Event> history, IList<Event> live}) split = useMemoized(() {
final items = controllerData.value; final items = controllerData.value?.events;
final anchor = anchorIdValue; final anchor = anchorIdValue;
if (items == null || anchor == null) { if (items == null || anchor == null) {
return (history: const .empty(), live: const .empty()); 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) { if (anchorIndex == -1) {
return (history: const .empty(), live: items); return (history: const .empty(), live: items);
@ -71,7 +72,7 @@ final class ChatScroll<T>({
if (position.extentAfter <= topThreshold) { if (position.extentAfter <= topThreshold) {
await loadOlder(); await loadOlder();
} else if (position.extentBefore <= bottomThreshold) { } else if (position.extentBefore <= bottomThreshold) {
await onReachedBottom(); await markRead();
} }
} }
@ -82,13 +83,13 @@ final class ChatScroll<T>({
return () { return () {
scrollController.removeListener(checkPosition); scrollController.removeListener(checkPosition);
}; };
}, [scrollController, controllerData, loadOlder, onReachedBottom]); }, [scrollController, controllerData, loadOlder, markRead]);
Future<void> jumpToId(String itemId) async { Future<void> jumpToId(String itemId) async {
if (!scrollController.hasClients) return; if (!scrollController.hasClients) return;
final historyIndex = split.history.indexWhere( final historyIndex = split.history.indexWhere(
(item) => id(item) == itemId, (item) => item.eventId == itemId,
); );
if (historyIndex != -1) { if (historyIndex != -1) {
@ -105,7 +106,9 @@ final class ChatScroll<T>({
curve: Curves.easeInOut, curve: Curves.easeInOut,
); );
} else { } else {
final liveIndex = split.live.indexWhere((item) => id(item) == itemId); final liveIndex = split.live.indexWhere(
(item) => item.eventId == itemId,
);
if (liveIndex != -1) { if (liveIndex != -1) {
// ignore: invalid_use_of_visible_for_testing_member // ignore: invalid_use_of_visible_for_testing_member

31
lib/models/room_chat.dart Normal file
View file

@ -0,0 +1,31 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/event.dart";
part "room_chat.freezed.dart";
part "room_chat.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const RoomChat({
required final IList<Event> events,
required final bool hasMore,
final HistoricalData? historicalData,
}) with _$RoomChat {
Map<String, Object?> toJson() => _$RoomChatToJson(this);
factory RoomChat.fromJson(Map<String, Object?> json) =>
_$RoomChatFromJson(json);
}
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const HistoricalData({
required final String end,
required final String start,
}) with _$HistoricalData {
Map<String, Object?> toJson() => _$HistoricalDataToJson(this);
factory HistoricalData.fromJson(Map<String, Object?> json) =>
_$HistoricalDataFromJson(json);
}

View file

@ -8,7 +8,7 @@ import "package:nexus/widgets/highlight_wrapper.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 ChatScroll<Event> scroll, required final ChatScroll scroll,
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,

View file

@ -68,9 +68,8 @@ final class const RoomChat({
final scroll = ChatScroll.use( final scroll = ChatScroll.use(
controllerData: controllerData, controllerData: controllerData,
id: (event) => event.eventId,
loadOlder: notifier.loadOlder, loadOlder: notifier.loadOlder,
onReachedBottom: () async { markRead: () async {
final room = ref.read( final room = ref.read(
RoomsController.provider.select((rooms) => rooms[roomId]), RoomsController.provider.select((rooms) => rooms[roomId]),
); );