From 5b585737623b605b62ba5d0477f27031a22e39f6 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 26 Sep 2026 12:18:10 -0400 Subject: [PATCH] allow paginating in the historical timeline --- lib/controllers/client.dart | 5 + lib/controllers/notifications.dart | 4 - lib/controllers/room_chat.dart | 130 ++++++++++++++------- lib/helpers/hooks/chat_scroll.dart | 40 +++---- lib/models/direction.dart | 8 ++ lib/models/paginate_manual.dart | 19 +++ lib/models/requests/get_event_context.dart | 2 +- lib/models/requests/get_mentions.dart | 2 +- lib/models/requests/paginate_manual.dart | 22 ++++ lib/models/room_chat.dart | 3 +- lib/widgets/room_chat/room_chat.dart | 2 +- 11 files changed, 166 insertions(+), 71 deletions(-) create mode 100644 lib/models/direction.dart create mode 100644 lib/models/paginate_manual.dart create mode 100644 lib/models/requests/paginate_manual.dart diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 5bee5be..4d0c972 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -17,6 +17,7 @@ import "package:nexus/models/gomuks_config.dart"; import "package:nexus/models/oauth_auth_code_response.dart"; import "package:nexus/models/open_graph_data.dart"; import "package:nexus/models/paginate.dart"; +import "package:nexus/models/paginate_manual.dart"; import "package:nexus/models/requests/deregister_pusher.dart"; import "package:nexus/models/requests/download_media.dart"; import "package:nexus/models/requests/get_event.dart"; @@ -30,6 +31,7 @@ import "package:nexus/models/requests/oauth/exchange_token.dart"; import "package:nexus/models/requests/oauth/get_auth_url.dart"; import "package:nexus/models/requests/oauth/register_client.dart"; import "package:nexus/models/requests/paginate.dart"; +import "package:nexus/models/requests/paginate_manual.dart"; import "package:nexus/models/requests/redact_event.dart"; import "package:nexus/models/requests/register_pusher.dart"; import "package:nexus/models/requests/report.dart"; @@ -229,6 +231,9 @@ class ClientController extends AsyncNotifier { Future paginate(PaginateRequest request) async => .fromJson(await _sendCommand("paginate", request.toJson())); + Future paginateManual(PaginateManualRequest request) async => + .fromJson(await _sendCommand("paginate_manual", request.toJson())); + Future getEventContext(GetEventContextRequest request) async => .fromJson(await _sendCommand("get_event_context", request.toJson())); diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart index f56aa6d..da4312d 100644 --- a/lib/controllers/notifications.dart +++ b/lib/controllers/notifications.dart @@ -7,8 +7,6 @@ typedef NotificationsRequest = (UnreadType? unreadType, String? roomId); class NotificationsController([final NotificationsRequest? request]) extends AsyncNotifier> { - static const limit = 20; - @override Future> build() async { final client = ref.read(ClientController.provider.notifier); @@ -19,7 +17,6 @@ class NotificationsController([final NotificationsRequest? request]) .new( maxTimestamp: .now(), unreadType: unreadType ?? .highlight, - limit: limit, roomId: roomId, ), ); @@ -39,7 +36,6 @@ class NotificationsController([final NotificationsRequest? request]) .new( maxTimestamp: lastTs, unreadType: unreadType ?? .highlight, - limit: limit, roomId: roomId, ), ); diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 7e1aeb2..23e4afd 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -10,6 +10,7 @@ import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/reaction.dart"; +import "package:nexus/models/direction.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/requests/redact_event.dart"; import "package:nexus/models/relation_type.dart"; @@ -60,14 +61,19 @@ class RoomChatController(final (String roomId, String? contextualEvent) info) if (info.$2 == null || timeline.map((e) => e.eventId).contains(info.$2)) { ref.watch(RoomsController.provider.select((rooms) => rooms[roomId])); - return .new(timeline: timeline, hasMore: room.hasMore); + return .new( + timeline: timeline, + hasMoreBackward: room.hasMore, + hasMoreForward: false, + ); } else { final context = await client.getEventContext( - .new(roomId: roomId, eventId: info.$2!, limit: 20), + .new(roomId: roomId, eventId: info.$2!), ); return .new( timeline: context.before.add(context.event).addAll(context.after), - hasMore: true, + hasMoreBackward: true, + hasMoreForward: true, historicalData: .new(start: context.start, end: context.end), ); } @@ -83,50 +89,88 @@ class RoomChatController(final (String roomId, String? contextualEvent) info) ), ); - Future loadOlder() async { - if (state.isLoading || state.value?.hasMore == false) return; - + Future paginate(Direction direction) async { + if (state.isLoading || state.value?.hasMoreBackward == false) return; + final chat = await future; state = .loading(); - final timelineKeys = ref - .read(RoomsController.provider.select((value) => value[info.$1])) - ?.timeline - .keys; - final response = await ref - .read(ClientController.provider.notifier) - .paginate( - .new( - roomId: info.$1, - maxTimelineId: timelineKeys?.isNotEmpty == true - ? timelineKeys?.reduce(min) - : null, + final client = ref.read(ClientController.provider.notifier); + + if (chat?.historicalData == null) { + final timelineKeys = ref + .read(RoomsController.provider.select((value) => value[info.$1])) + ?.timeline + .keys; + final response = await client.paginate( + .new( + roomId: info.$1, + maxTimelineId: timelineKeys?.isNotEmpty == true + ? timelineKeys?.reduce(min) + : null, + ), + ); + + if (response.events.isEmpty) { + state = .data(state.value); + } + + ref + .read(RoomsController.provider.notifier) + .update( + IMap({ + info.$1: Room( + events: IMap.fromIterable( + response.events.addAll(response.relatedEvents), + keyMapper: (event) => event.rowId, + valueMapper: (event) => event, + ), + hasMore: response.hasMore, + timeline: IMap.fromIterable( + response.events, + keyMapper: (event) => event.timelineRowId, + valueMapper: (event) => event.rowId, + ), + ), + }), + .new(), + ); + } else { + final paginationResponse = await client.paginateManual( + .new( + roomId: info.$1, + direction: direction, + since: direction == .forward + ? chat!.historicalData!.end + : chat!.historicalData!.start, + ), + ); + + state = .data( + .new( + timeline: direction == .forward + ? chat.timeline.addAll(paginationResponse.events) + : paginationResponse.events.addAll(chat.timeline), + hasMoreForward: + direction == .forward && paginationResponse.nextBatch == null + ? false + : chat.hasMoreForward, + hasMoreBackward: + direction == .backward && paginationResponse.nextBatch == null + ? false + : chat.hasMoreBackward, + historicalData: chat.historicalData?.copyWith( + start: + (direction == .backward + ? paginationResponse.nextBatch + : null) ?? + chat.historicalData!.start, + end: + (direction == .forward ? paginationResponse.nextBatch : null) ?? + chat.historicalData!.end, ), - ); - - if (response.events.isEmpty) { - state = .data(state.value); + ), + ); } - - ref - .read(RoomsController.provider.notifier) - .update( - IMap({ - info.$1: Room( - events: IMap.fromIterable( - response.events.addAll(response.relatedEvents), - keyMapper: (event) => event.rowId, - valueMapper: (event) => event, - ), - hasMore: response.hasMore, - timeline: IMap.fromIterable( - response.events, - keyMapper: (event) => event.timelineRowId, - valueMapper: (event) => event.rowId, - ), - ), - }), - .new(), - ); } Future send( diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index f0d4f1d..9275097 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -4,6 +4,7 @@ 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:nexus/models/direction.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/room_chat.dart"; import "package:super_sliver_list/super_sliver_list.dart"; @@ -20,7 +21,7 @@ final class ChatScroll({ }) { factory use({ required AsyncValue controllerData, - required Future Function() loadOlder, + required Future Function(Direction direction) paginate, required Future Function() markRead, required String? contextualEvent, required void Function(String id) setContextualEvent, @@ -118,32 +119,31 @@ final class ChatScroll({ ); }, [controllerData, anchorId.value]); - useEffect( - () { - const topThreshold = 500.0; - const bottomThreshold = 50.0; + useEffect(() { + const loadThreshold = 500.0; + const readThreshold = 50.0; - Future checkPosition() async { - if (!scrollController.hasClients) return; - if (contextualEvent != null) return; + Future checkPosition() async { + if (!scrollController.hasClients) return; - final position = scrollController.position; + final position = scrollController.position; - if (position.extentAfter <= topThreshold) { - await loadOlder(); - } else if (position.extentBefore <= bottomThreshold) { - await markRead(); - } + if (position.extentAfter <= loadThreshold) { + await paginate(Direction.backward); + } else if (contextualEvent != null && + position.extentBefore <= loadThreshold) { + await paginate(Direction.forward); + } else if (position.extentBefore <= readThreshold) { + await markRead(); } + } - scrollController.addListener(checkPosition); + scrollController.addListener(checkPosition); - WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition()); + WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition()); - return () => scrollController.removeListener(checkPosition); - }, - [scrollController, controllerData, loadOlder, markRead, contextualEvent], - ); + return () => scrollController.removeListener(checkPosition); + }, [scrollController, controllerData, paginate, markRead, contextualEvent]); double? resolveOffset(String itemId) { final historyIndex = split.history.indexWhere( diff --git a/lib/models/direction.dart b/lib/models/direction.dart new file mode 100644 index 0000000..8c4947e --- /dev/null +++ b/lib/models/direction.dart @@ -0,0 +1,8 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +enum Direction { + @JsonValue("f") + forward, + @JsonValue("b") + backward, +} diff --git a/lib/models/paginate_manual.dart b/lib/models/paginate_manual.dart new file mode 100644 index 0000000..9f45c1f --- /dev/null +++ b/lib/models/paginate_manual.dart @@ -0,0 +1,19 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/event.dart"; + +part "paginate_manual.freezed.dart"; +part "paginate_manual.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PaginateManual({ + required final IList events, + final IList relatedEvents = const IList.empty(), + required final String? nextBatch, +}) with _$PaginateManual { + Map toJson() => _$PaginateManualToJson(this); + + factory PaginateManual.fromJson(Map json) => + _$PaginateManualFromJson(json); +} diff --git a/lib/models/requests/get_event_context.dart b/lib/models/requests/get_event_context.dart index 6b80e38..68f23a0 100644 --- a/lib/models/requests/get_event_context.dart +++ b/lib/models/requests/get_event_context.dart @@ -8,7 +8,7 @@ part "get_event_context.g.dart"; class const GetEventContextRequest({ required final String roomId, required final String eventId, - required final int limit, + final int limit = 20, }) with _$GetEventContextRequest { Map toJson() => _$GetEventContextRequestToJson(this); diff --git a/lib/models/requests/get_mentions.dart b/lib/models/requests/get_mentions.dart index 5df6870..1512b6f 100644 --- a/lib/models/requests/get_mentions.dart +++ b/lib/models/requests/get_mentions.dart @@ -9,7 +9,7 @@ part "get_mentions.g.dart"; class GetMentionsRequest({ @EpochDateTimeConverter() required final DateTime maxTimestamp, @JsonKey(name: "type") required final UnreadType unreadType, - required final int limit, + final int limit = 20, final String? roomId, }) with _$GetMentionsRequest { Map toJson() => _$GetMentionsRequestToJson(this); diff --git a/lib/models/requests/paginate_manual.dart b/lib/models/requests/paginate_manual.dart new file mode 100644 index 0000000..9f8a1d3 --- /dev/null +++ b/lib/models/requests/paginate_manual.dart @@ -0,0 +1,22 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/direction.dart"; + +part "paginate_manual.freezed.dart"; +part "paginate_manual.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PaginateManualRequest({ + required final String roomId, + // Root event ID of a thread to paginate + final String? threadRoot, + // Can be null for starting pagination of a thread + final String? since, + required final Direction direction, + final int limit = 20, +}) with _$PaginateManualRequest { + Map toJson() => _$PaginateManualRequestToJson(this); + + factory PaginateManualRequest.fromJson(Map json) => + _$PaginateManualRequestFromJson(json); +} diff --git a/lib/models/room_chat.dart b/lib/models/room_chat.dart index 6044909..a038a88 100644 --- a/lib/models/room_chat.dart +++ b/lib/models/room_chat.dart @@ -9,7 +9,8 @@ part "room_chat.g.dart"; @JsonSerializable() class const RoomChat({ required final IList timeline, - required final bool hasMore, + required final bool hasMoreForward, + required final bool hasMoreBackward, final HistoricalData? historicalData, }) with _$RoomChat { Map toJson() => _$RoomChatToJson(this); diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index be0a606..7648e00 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -73,7 +73,7 @@ final class const RoomChat({ final scroll = ChatScroll.use( controllerData: controllerData, - loadOlder: notifier.loadOlder, + paginate: notifier.paginate, contextualEvent: contextualEvent.value, setContextualEvent: (id) => contextualEvent.value = id, markRead: () async {