From 7d8505bdf11fcab8471568b5d5c326ccc4c2e951 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 25 Sep 2026 11:45:18 -0400 Subject: [PATCH 01/12] add getEventContext to ClientController --- lib/controllers/client.dart | 5 +++++ lib/models/event_context.dart | 22 ++++++++++++++++++++++ lib/models/requests/get_event_context.dart | 17 +++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 lib/models/event_context.dart create mode 100644 lib/models/requests/get_event_context.dart diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 014966f..5bee5be 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -12,6 +12,7 @@ import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/models/capabilities.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; +import "package:nexus/models/event_context.dart"; import "package:nexus/models/gomuks_config.dart"; import "package:nexus/models/oauth_auth_code_response.dart"; import "package:nexus/models/open_graph_data.dart"; @@ -19,6 +20,7 @@ import "package:nexus/models/paginate.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"; +import "package:nexus/models/requests/get_event_context.dart"; import "package:nexus/models/requests/get_mentions.dart"; import "package:nexus/models/requests/get_related_events.dart"; import "package:nexus/models/requests/get_room_state.dart"; @@ -227,6 +229,9 @@ class ClientController extends AsyncNotifier { Future paginate(PaginateRequest request) async => .fromJson(await _sendCommand("paginate", request.toJson())); + Future getEventContext(GetEventContextRequest request) async => + .fromJson(await _sendCommand("get_event_context", request.toJson())); + Future getProfile(String userId) async { try { return .fromJson(await _sendCommand("get_profile", {"user_id": userId})); diff --git a/lib/models/event_context.dart b/lib/models/event_context.dart new file mode 100644 index 0000000..e7b1cb1 --- /dev/null +++ b/lib/models/event_context.dart @@ -0,0 +1,22 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/event.dart"; + +part "event_context.freezed.dart"; +part "event_context.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const EventContext({ + required final IList events, + required final IList before, + required final IList after, + required final String start, + required final String end, + final IList relatedEvents = const IList.empty(), +}) with _$EventContext { + Map toJson() => _$EventContextToJson(this); + + factory EventContext.fromJson(Map json) => + _$EventContextFromJson(json); +} diff --git a/lib/models/requests/get_event_context.dart b/lib/models/requests/get_event_context.dart new file mode 100644 index 0000000..6b80e38 --- /dev/null +++ b/lib/models/requests/get_event_context.dart @@ -0,0 +1,17 @@ +import "package:freezed_annotation/freezed_annotation.dart"; + +part "get_event_context.freezed.dart"; +part "get_event_context.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const GetEventContextRequest({ + required final String roomId, + required final String eventId, + required final int limit, +}) with _$GetEventContextRequest { + Map toJson() => _$GetEventContextRequestToJson(this); + + factory GetEventContextRequest.fromJson(Map json) => + _$GetEventContextRequestFromJson(json); +} -- 2.55.0 From 08e60a87df90d15362744cb02965719d49412c4a Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 25 Sep 2026 12:03:15 -0400 Subject: [PATCH 02/12] refactor for RoomChatController to return a RoomChat Will help with historical timeline support. --- lib/controllers/room_chat.dart | 53 +++++++++++++----------- lib/helpers/hooks/chat_scroll.dart | 35 +++++++++------- lib/models/room_chat.dart | 31 ++++++++++++++ lib/widgets/room_chat/chat_timeline.dart | 2 +- lib/widgets/room_chat/room_chat.dart | 3 +- 5 files changed, 80 insertions(+), 44 deletions(-) create mode 100644 lib/models/room_chat.dart diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 7b076f7..2db473e 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -15,11 +15,11 @@ import "package:nexus/models/requests/redact_event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/room.dart"; +import "package:nexus/models/room_chat.dart"; -class RoomChatController(final String roomId) - extends AsyncNotifier?> { +class RoomChatController(final String roomId) extends AsyncNotifier { @override - Future?> build() async { + Future build() async { final client = ref.read(ClientController.provider.notifier); final room = ref.watch( RoomsController.provider.select((rooms) => rooms[roomId]), @@ -32,28 +32,31 @@ class RoomChatController(final String roomId) await ref.read(RoomsController.provider.notifier).addState(roomId, state); } - return room.timeline - .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) - .map((element) => element.value) - .toIList() - .addAll(room.clientSticky) - .map((entry) { - final foundEvent = entry == null ? null : room.events[entry]; + return .new( + events: room.timeline + .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) + .map((element) => element.value) + .toIList() + .addAll(room.clientSticky) + .map((entry) { + final foundEvent = entry == null ? null : room.events[entry]; - final editedEvent = - foundEvent == null || foundEvent.lastEditRowId == 0 - ? null - : room.events[foundEvent.lastEditRowId]; + final editedEvent = + foundEvent == null || foundEvent.lastEditRowId == 0 + ? null + : room.events[foundEvent.lastEditRowId]; - return editedEvent == null - ? foundEvent - : foundEvent?.copyWith( - content: editedEvent.content, - localContent: editedEvent.localContent, - ); - }) - .nonNulls - .toIList(); + return editedEvent == null + ? foundEvent + : foundEvent?.copyWith( + content: editedEvent.content, + localContent: editedEvent.localContent, + ); + }) + .nonNulls + .toIList(), + hasMore: room.hasMore, + ); } Future deleteMessage(Event event, {String? reason}) => ref @@ -67,7 +70,7 @@ class RoomChatController(final String roomId) ); Future loadOlder() async { - if (state.isLoading) return; + if (state.isLoading || state.value?.hasMore == false) return; state = .loading(); @@ -224,7 +227,7 @@ class RoomChatController(final String roomId) } static final provider = AsyncNotifierProvider.family - .autoDispose?, String>( + .autoDispose( RoomChatController.new, ); } diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index efc023e..d8bfae8 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -2,22 +2,23 @@ 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/event.dart"; +import "package:nexus/models/room_chat.dart"; import "package:super_sliver_list/super_sliver_list.dart"; -final class ChatScroll({ - required final IList historyItems, - required final IList liveItems, +final class ChatScroll({ + required final IList historyItems, + required final IList liveItems, required final GlobalKey centerKey, required final ListController historyListController, required final ListController liveListController, required final ScrollController scrollController, required final Future Function(String id) jumpToId, }) { - static ChatScroll use({ - required AsyncValue?> controllerData, - required String Function(T item) id, + static ChatScroll use({ + required AsyncValue controllerData, required Future Function() loadOlder, - required Future Function() onReachedBottom, + required Future Function() markRead, }) { final historyListController = useRef(ListController()); final liveListController = useRef(ListController()); @@ -31,23 +32,23 @@ final class ChatScroll({ useEffect(() { if (anchorId.value == null) { if (controllerData case AsyncData(:final value?) - when value.isNotEmpty) { - anchorId.value = id(value.last); + when value.events.isNotEmpty) { + anchorId.value = value.events.last.eventId; } } return null; }, [controllerData]); - final ({IList history, IList live}) split = useMemoized(() { - final items = controllerData.value; + final ({IList history, IList live}) split = useMemoized(() { + final items = controllerData.value?.events; final anchor = anchorIdValue; if (items == null || anchor == null) { 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) { return (history: const .empty(), live: items); @@ -71,7 +72,7 @@ final class ChatScroll({ if (position.extentAfter <= topThreshold) { await loadOlder(); } else if (position.extentBefore <= bottomThreshold) { - await onReachedBottom(); + await markRead(); } } @@ -82,13 +83,13 @@ final class ChatScroll({ return () { scrollController.removeListener(checkPosition); }; - }, [scrollController, controllerData, loadOlder, onReachedBottom]); + }, [scrollController, controllerData, loadOlder, markRead]); Future jumpToId(String itemId) async { if (!scrollController.hasClients) return; final historyIndex = split.history.indexWhere( - (item) => id(item) == itemId, + (item) => item.eventId == itemId, ); if (historyIndex != -1) { @@ -105,7 +106,9 @@ final class ChatScroll({ curve: Curves.easeInOut, ); } else { - final liveIndex = split.live.indexWhere((item) => id(item) == itemId); + final liveIndex = split.live.indexWhere( + (item) => item.eventId == itemId, + ); if (liveIndex != -1) { // ignore: invalid_use_of_visible_for_testing_member diff --git a/lib/models/room_chat.dart b/lib/models/room_chat.dart new file mode 100644 index 0000000..e952d42 --- /dev/null +++ b/lib/models/room_chat.dart @@ -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 events, + required final bool hasMore, + final HistoricalData? historicalData, +}) with _$RoomChat { + Map toJson() => _$RoomChatToJson(this); + + factory RoomChat.fromJson(Map json) => + _$RoomChatFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const HistoricalData({ + required final String end, + required final String start, +}) with _$HistoricalData { + Map toJson() => _$HistoricalDataToJson(this); + + factory HistoricalData.fromJson(Map json) => + _$HistoricalDataFromJson(json); +} diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index 434145c..3bc601c 100644 --- a/lib/widgets/room_chat/chat_timeline.dart +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -8,7 +8,7 @@ import "package:nexus/widgets/highlight_wrapper.dart"; import "package:super_sliver_list/super_sliver_list.dart"; class const ChatTimeline({ - required final ChatScroll scroll, + required final ChatScroll scroll, required final Future Function(String) jumpToId, required final IList Function(Event) getEventOptions, required final String? highlightedEvent, diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 951d9e3..9d81c16 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -68,9 +68,8 @@ final class const RoomChat({ final scroll = ChatScroll.use( controllerData: controllerData, - id: (event) => event.eventId, loadOlder: notifier.loadOlder, - onReachedBottom: () async { + markRead: () async { final room = ref.read( RoomsController.provider.select((rooms) => rooms[roomId]), ); -- 2.55.0 From 66bd75c2f1ea4ad36a50553ab3f9f0c894db79a3 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 25 Sep 2026 12:04:41 -0400 Subject: [PATCH 03/12] make ChatScroll.use a factory --- lib/helpers/hooks/chat_scroll.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index d8bfae8..9efd3b2 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -15,7 +15,7 @@ final class ChatScroll({ required final ScrollController scrollController, required final Future Function(String id) jumpToId, }) { - static ChatScroll use({ + factory use({ required AsyncValue controllerData, required Future Function() loadOlder, required Future Function() markRead, -- 2.55.0 From 66bb820a59a7f0d894dfdb3f1bd6f0b2795e8c3d Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 25 Sep 2026 12:34:15 -0400 Subject: [PATCH 04/12] make RoomChatController family take an event ID --- lib/controllers/room_chat.dart | 26 ++++++++++--------- .../extensions/build_event_options.dart | 4 ++- lib/widgets/reaction_row.dart | 6 +++-- lib/widgets/room_chat/room_chat.dart | 8 ++++-- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 2db473e..4043922 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -17,9 +17,11 @@ import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/room.dart"; import "package:nexus/models/room_chat.dart"; -class RoomChatController(final String roomId) extends AsyncNotifier { +class RoomChatController(final (String roomId, String? withEventId) info) + extends AsyncNotifier { @override Future build() async { + final (roomId, eventId) = info; final client = ref.read(ClientController.provider.notifier); final room = ref.watch( RoomsController.provider.select((rooms) => rooms[roomId]), @@ -64,7 +66,7 @@ class RoomChatController(final String roomId) extends AsyncNotifier { .redactEvent( RedactEventRequest( eventId: event.eventId, - roomId: roomId, + roomId: info.$1, reason: reason, ), ); @@ -75,14 +77,14 @@ class RoomChatController(final String roomId) extends AsyncNotifier { state = .loading(); final timelineKeys = ref - .read(RoomsController.provider.select((value) => value[roomId])) + .read(RoomsController.provider.select((value) => value[info.$1])) ?.timeline .keys; final response = await ref .read(ClientController.provider.notifier) .paginate( .new( - roomId: roomId, + roomId: info.$1, maxTimelineId: timelineKeys?.isNotEmpty == true ? timelineKeys?.reduce(min) : null, @@ -97,7 +99,7 @@ class RoomChatController(final String roomId) extends AsyncNotifier { .read(RoomsController.provider.notifier) .update( IMap({ - roomId: Room( + info.$1: Room( events: IMap.fromIterable( response.events.addAll(response.relatedEvents), keyMapper: (event) => event.rowId, @@ -126,7 +128,7 @@ class RoomChatController(final String roomId) extends AsyncNotifier { if (relationType == .edit) { baseContent = relation?.content; } else { - final provider = AttachmentController.provider(roomId); + final provider = AttachmentController.provider(info.$1); baseContent = ref.read(provider)?.$2; ref.invalidate(provider); } @@ -146,7 +148,7 @@ class RoomChatController(final String roomId) extends AsyncNotifier { final client = ref.read(ClientController.provider.notifier); final event = await client.sendMessage( SendMessageRequest( - roomId: roomId, + roomId: info.$1, baseContent: baseContent, mentions: Mentions( userIds: [ @@ -168,7 +170,7 @@ class RoomChatController(final String roomId) extends AsyncNotifier { .read(RoomsController.provider.notifier) .update( .new({ - roomId: .new( + info.$1: .new( events: .new({event.rowId: event}), clientSticky: .new({event.rowId}), ), @@ -185,7 +187,7 @@ class RoomChatController(final String roomId) extends AsyncNotifier { final client = ref.read(ClientController.provider.notifier); final allReactionEvents = await client.getRelatedEvents( .new( - roomId: roomId, + roomId: info.$1, eventId: event.eventId, relationType: "m.annotation", ), @@ -206,7 +208,7 @@ class RoomChatController(final String roomId) extends AsyncNotifier { if (reactionEvent != null) { await ref .watch(ClientController.provider.notifier) - .redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId)); + .redactEvent(.new(eventId: reactionEvent.eventId, roomId: info.$1)); } } @@ -215,7 +217,7 @@ class RoomChatController(final String roomId) extends AsyncNotifier { await client.sendEvent( .new( - roomId: roomId, + roomId: info.$1, type: EventType.reaction.type, content: ReactionContent(key: reaction), synchronous: true, @@ -227,7 +229,7 @@ class RoomChatController(final String roomId) extends AsyncNotifier { } static final provider = AsyncNotifierProvider.family - .autoDispose( + .autoDispose( RoomChatController.new, ); } diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart index 5319f2b..a95da6d 100644 --- a/lib/helpers/extensions/build_event_options.dart +++ b/lib/helpers/extensions/build_event_options.dart @@ -27,7 +27,9 @@ extension BuildEventOptions on Event { final theme = Theme.of(context); final danger = theme.colorScheme.error; - final notifier = ref.read(RoomChatController.provider(roomId).notifier); + final notifier = ref.read( + RoomChatController.provider((roomId, null)).notifier, + ); final client = ref.read(ClientController.provider.notifier); final isPinned = ref diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart index 56f2356..195aa4f 100644 --- a/lib/widgets/reaction_row.dart +++ b/lib/widgets/reaction_row.dart @@ -64,8 +64,10 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { enabled.value = false; try { final controller = ref.watch( - RoomChatController.provider(event.roomId) - .notifier, + RoomChatController.provider(( + event.roomId, + null, + )).notifier, ); if (selected) { diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 9d81c16..186b2a6 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -24,13 +24,14 @@ final class const RoomChat({ required final String? roomId, required final bool isDesktop, required final bool showMembersByDefault, + final String? initialHighlightedEvent, super.key, }) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final relatedEvent = useState(null); final relationType = useState(RelationType.reply); - final highlightedEvent = useState(null); + final highlightedEvent = useState(initialHighlightedEvent); final composerSize = useState(64); @@ -59,7 +60,10 @@ final class const RoomChat({ final roomId = this.roomId!; - final controllerProvider = RoomChatController.provider(roomId); + final controllerProvider = RoomChatController.provider(( + roomId, + initialHighlightedEvent, + )); final notifier = ref.watch(controllerProvider.notifier); final client = ref.read(ClientController.provider.notifier); -- 2.55.0 From 24aa0cae1dae670cb9649abd10ed27b13372990c Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 25 Sep 2026 13:01:32 -0400 Subject: [PATCH 05/12] small refactors --- lib/controllers/room_chat.dart | 64 +++++++++++++++++----------- lib/helpers/hooks/chat_scroll.dart | 32 +++++++------- lib/models/room.dart | 12 ++++-- lib/models/room_chat.dart | 2 +- lib/widgets/room_chat/room_chat.dart | 3 +- 5 files changed, 67 insertions(+), 46 deletions(-) diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 4043922..35dee48 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -17,7 +17,7 @@ import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/room.dart"; import "package:nexus/models/room_chat.dart"; -class RoomChatController(final (String roomId, String? withEventId) info) +class RoomChatController(final (String roomId, String? contextualEvent) info) extends AsyncNotifier { @override Future build() async { @@ -34,31 +34,47 @@ class RoomChatController(final (String roomId, String? withEventId) info) await ref.read(RoomsController.provider.notifier).addState(roomId, state); } - return .new( - events: room.timeline - .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) - .map((element) => element.value) - .toIList() - .addAll(room.clientSticky) - .map((entry) { - final foundEvent = entry == null ? null : room.events[entry]; + if (info.$2 == null || + room.events.values.map((e) => e.eventId).contains(info.$2)) { + return .new( + events: room.timeline + .toEntryIList( + compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0), + ) + .map((element) => element.value) + .toIList() + .addAll(room.clientSticky) + .map((entry) { + final foundEvent = entry == null ? null : room.events[entry]; - final editedEvent = - foundEvent == null || foundEvent.lastEditRowId == 0 - ? null - : room.events[foundEvent.lastEditRowId]; + final editedEvent = + foundEvent == null || foundEvent.lastEditRowId == 0 + ? null + : room.events[foundEvent.lastEditRowId]; - return editedEvent == null - ? foundEvent - : foundEvent?.copyWith( - content: editedEvent.content, - localContent: editedEvent.localContent, - ); - }) - .nonNulls - .toIList(), - hasMore: room.hasMore, - ); + return editedEvent == null + ? foundEvent + : foundEvent?.copyWith( + content: editedEvent.content, + localContent: editedEvent.localContent, + ); + }) + .nonNulls + .toIList(), + hasMore: room.hasMore, + ); + } + { + final context = await client.getEventContext( + .new(roomId: roomId, eventId: info.$2!, limit: 20), + ); + + return .new( + events: context.events, + hasMore: true, + historicalData: .new(start: context.start, end: context.end), + ); + } } Future deleteMessage(Event event, {String? reason}) => ref diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index 9efd3b2..6420726 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -92,19 +92,14 @@ final class ChatScroll({ (item) => item.eventId == itemId, ); + double? offset; if (historyIndex != -1) { // TODO: Replace SuperSliverView because of the bug that requires this: #94 // ignore: invalid_use_of_visible_for_testing_member - final offset = historyListController.value.getOffsetToReveal( + offset = historyListController.value.getOffsetToReveal( historyIndex, 0.5, ); - - await scrollController.animateTo( - offset, - duration: const Duration(milliseconds: 700), - curve: Curves.easeInOut, - ); } else { final liveIndex = split.live.indexWhere( (item) => item.eventId == itemId, @@ -112,18 +107,21 @@ final class ChatScroll({ if (liveIndex != -1) { // ignore: invalid_use_of_visible_for_testing_member - final offset = liveListController.value.getOffsetToReveal( - liveIndex, - 0.5, - ); - - await scrollController.animateTo( - offset, - duration: const Duration(milliseconds: 700), - curve: Curves.easeInOut, - ); + offset = liveListController.value.getOffsetToReveal(liveIndex, 0.5); } } + + if (offset == null) { + // not in current timeline + } + + if (offset != null) { + await scrollController.animateTo( + offset, + duration: const Duration(milliseconds: 700), + curve: Curves.easeInOut, + ); + } } return .new( diff --git a/lib/models/room.dart b/lib/models/room.dart index 6d7553f..96b29b4 100644 --- a/lib/models/room.dart +++ b/lib/models/room.dart @@ -12,17 +12,26 @@ part "room.g.dart"; class const Room({ @JsonKey(name: "meta") final RoomMetadata? metadata, + /// [timeline] is an IMap of timelineRowId to eventRowId @JsonKey(fromJson: Room.timelineTupleJsonToIMap) final IMap timeline = const IMap.empty(), + /// [clientSticky] is an ISet of eventRowId + @JsonKey(includeFromJson: false, includeToJson: false) final ISet clientSticky = const ISet.empty(), + /// [events] is an IMap of eventRowId to event @JsonKey(fromJson: Room.eventsJsonToIMap) final IMap events = const IMap.empty(), final bool reset = false, + + @JsonKey(includeFromJson: false, includeToJson: false) final bool hasFetchedState = false, + + @JsonKey(includeFromJson: false, includeToJson: false) final bool hasFetchedMembers = false, + final IMap> state = const IMap.empty(), final IMap> receipts = const IMap.empty(), @@ -32,9 +41,6 @@ class const Room({ // IMap accountData, // IList notifications, }) with _$Room { - /// [timeline] is an IMap of timelineRowId to eventRowId - /// [events] is an IMap of eventRowId to event - /// [clientSticky] is an ISet of eventRowId static IMap timelineTupleJsonToIMap(List json) => IMap.fromEntries( json.map( diff --git a/lib/models/room_chat.dart b/lib/models/room_chat.dart index e952d42..ca9799c 100644 --- a/lib/models/room_chat.dart +++ b/lib/models/room_chat.dart @@ -21,8 +21,8 @@ class const RoomChat({ @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const HistoricalData({ - required final String end, required final String start, + required final String end, }) with _$HistoricalData { Map toJson() => _$HistoricalDataToJson(this); diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 186b2a6..2aee4a5 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -31,6 +31,7 @@ final class const RoomChat({ Widget build(BuildContext context, WidgetRef ref) { final relatedEvent = useState(null); final relationType = useState(RelationType.reply); + final contextualEvent = useState(initialHighlightedEvent); final highlightedEvent = useState(initialHighlightedEvent); final composerSize = useState(64); @@ -62,7 +63,7 @@ final class const RoomChat({ final controllerProvider = RoomChatController.provider(( roomId, - initialHighlightedEvent, + contextualEvent.value, )); final notifier = ref.watch(controllerProvider.notifier); -- 2.55.0 From dfb8eb8a482a69f8b72f53787750ba1b20ee6667 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 25 Sep 2026 14:04:13 -0400 Subject: [PATCH 06/12] initial jump mechanism implemented --- lib/controllers/room_chat.dart | 60 ++++++++++++++---------------- lib/helpers/hooks/chat_scroll.dart | 6 +-- lib/models/event_context.dart | 2 +- lib/models/room_chat.dart | 2 +- 4 files changed, 33 insertions(+), 37 deletions(-) diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 35dee48..7e1aeb2 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -23,7 +23,7 @@ class RoomChatController(final (String roomId, String? contextualEvent) info) Future build() async { final (roomId, eventId) = info; final client = ref.read(ClientController.provider.notifier); - final room = ref.watch( + final room = ref.read( RoomsController.provider.select((rooms) => rooms[roomId]), ); @@ -34,43 +34,39 @@ class RoomChatController(final (String roomId, String? contextualEvent) info) await ref.read(RoomsController.provider.notifier).addState(roomId, state); } - if (info.$2 == null || - room.events.values.map((e) => e.eventId).contains(info.$2)) { - return .new( - events: room.timeline - .toEntryIList( - compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0), - ) - .map((element) => element.value) - .toIList() - .addAll(room.clientSticky) - .map((entry) { - final foundEvent = entry == null ? null : room.events[entry]; + final timeline = room.timeline + .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) + .map((element) => element.value) + .toIList() + .addAll(room.clientSticky) + .map((entry) { + final foundEvent = entry == null ? null : room.events[entry]; - final editedEvent = - foundEvent == null || foundEvent.lastEditRowId == 0 - ? null - : room.events[foundEvent.lastEditRowId]; + final editedEvent = + foundEvent == null || foundEvent.lastEditRowId == 0 + ? null + : room.events[foundEvent.lastEditRowId]; - return editedEvent == null - ? foundEvent - : foundEvent?.copyWith( - content: editedEvent.content, - localContent: editedEvent.localContent, - ); - }) - .nonNulls - .toIList(), - hasMore: room.hasMore, - ); - } - { + return editedEvent == null + ? foundEvent + : foundEvent?.copyWith( + content: editedEvent.content, + localContent: editedEvent.localContent, + ); + }) + .nonNulls + .toIList(); + + 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); + } else { final context = await client.getEventContext( .new(roomId: roomId, eventId: info.$2!, limit: 20), ); - return .new( - events: context.events, + timeline: context.before.add(context.event).addAll(context.after), hasMore: true, historicalData: .new(start: context.start, end: context.end), ); diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index 6420726..065a215 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -32,8 +32,8 @@ final class ChatScroll({ useEffect(() { if (anchorId.value == null) { if (controllerData case AsyncData(:final value?) - when value.events.isNotEmpty) { - anchorId.value = value.events.last.eventId; + when value.timeline.isNotEmpty) { + anchorId.value = value.timeline.last.eventId; } } @@ -41,7 +41,7 @@ final class ChatScroll({ }, [controllerData]); final ({IList history, IList live}) split = useMemoized(() { - final items = controllerData.value?.events; + final items = controllerData.value?.timeline; final anchor = anchorIdValue; if (items == null || anchor == null) { diff --git a/lib/models/event_context.dart b/lib/models/event_context.dart index e7b1cb1..6103c7d 100644 --- a/lib/models/event_context.dart +++ b/lib/models/event_context.dart @@ -8,7 +8,7 @@ part "event_context.g.dart"; @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const EventContext({ - required final IList events, + required final Event event, required final IList before, required final IList after, required final String start, diff --git a/lib/models/room_chat.dart b/lib/models/room_chat.dart index ca9799c..6044909 100644 --- a/lib/models/room_chat.dart +++ b/lib/models/room_chat.dart @@ -8,7 +8,7 @@ part "room_chat.g.dart"; @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const RoomChat({ - required final IList events, + required final IList timeline, required final bool hasMore, final HistoricalData? historicalData, }) with _$RoomChat { -- 2.55.0 From 3461fc5012eb243a52f13a3ad9f775be5e631ee0 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 25 Sep 2026 21:21:39 -0400 Subject: [PATCH 07/12] working jumps with scroll / highlight --- lib/helpers/hooks/chat_scroll.dart | 163 +++++++++++++++-------- lib/widgets/room_chat/chat_timeline.dart | 7 +- lib/widgets/room_chat/room_chat.dart | 2 + 3 files changed, 117 insertions(+), 55 deletions(-) diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index 065a215..c2d047d 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; @@ -10,6 +12,7 @@ final class ChatScroll({ required final IList historyItems, required final IList liveItems, required final GlobalKey centerKey, + required final GlobalKey anchorItemKey, required final ListController historyListController, required final ListController liveListController, required final ScrollController scrollController, @@ -19,30 +22,64 @@ final class ChatScroll({ required AsyncValue controllerData, required Future Function() loadOlder, required Future Function() markRead, + required String? contextualEvent, + required void Function(String id) setContextualEvent, }) { - final historyListController = useRef(ListController()); - final liveListController = useRef(ListController()); + final anchorId = useState(null); + + final anchorItemKey = useMemoized(GlobalKey.new, [anchorId.value]); + final anchorItemKeyRef = useRef(anchorItemKey); + anchorItemKeyRef.value = anchorItemKey; + + final historyListController = useMemoized(ListController.new, [ + anchorId.value, + ]); + final liveListController = useMemoized(ListController.new, [ + anchorId.value, + ]); final scrollController = useScrollController(); final centerKey = useMemoized(GlobalKey.new); - final anchorId = useState(null); - - final anchorIdValue = anchorId.value; + final pendingAnchorTarget = useState(null); useEffect(() { if (anchorId.value == null) { if (controllerData case AsyncData(:final value?) when value.timeline.isNotEmpty) { - anchorId.value = value.timeline.last.eventId; + final hasContextualEvent = value.timeline.any( + (event) => event.eventId == contextualEvent, + ); + + anchorId.value = hasContextualEvent + ? contextualEvent + : value.timeline.last.eventId; } } return null; - }, [controllerData]); + }, [controllerData, contextualEvent]); + + useEffect(() { + final target = pendingAnchorTarget.value; + if (target == null) return null; + + final found = + controllerData.value?.timeline.any( + (event) => event.eventId == target, + ) ?? + false; + + if (found || controllerData is AsyncError) { + if (found) anchorId.value = target; + pendingAnchorTarget.value = null; + } + + return null; + }, [controllerData, pendingAnchorTarget.value]); final ({IList history, IList live}) split = useMemoized(() { final items = controllerData.value?.timeline; - final anchor = anchorIdValue; + final anchor = anchorId.value; if (items == null || anchor == null) { return (history: const .empty(), live: const .empty()); @@ -58,62 +95,58 @@ final class ChatScroll({ history: items.take(anchorIndex).toIList().reversed.toIList(), live: items.skip(anchorIndex).toIList(), ); - }, [controllerData, anchorIdValue]); + }, [controllerData, anchorId.value]); - useEffect(() { - const topThreshold = 500.0; - const bottomThreshold = 50.0; + useEffect( + () { + const topThreshold = 500.0; + const bottomThreshold = 50.0; - Future checkPosition() async { - if (!scrollController.hasClients) return; + Future checkPosition() async { + if (!scrollController.hasClients) return; + if (contextualEvent != null) 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 <= topThreshold) { + await loadOlder(); + } else if (position.extentBefore <= bottomThreshold) { + await markRead(); + } } + + scrollController.addListener(checkPosition); + + WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition()); + + return () => scrollController.removeListener(checkPosition); + }, + [scrollController, controllerData, loadOlder, markRead, contextualEvent], + ); + + double? resolveOffset(String itemId) { + final historyIndex = split.history.indexWhere( + (item) => item.eventId == itemId, + ); + if (historyIndex != -1) { + // TODO: Replace SuperSliverView because of the bug that requires this: #94 + // ignore: invalid_use_of_visible_for_testing_member + return historyListController.getOffsetToReveal(historyIndex, 0.5); } - scrollController.addListener(checkPosition); + final liveIndex = split.live.indexWhere((item) => item.eventId == itemId); + if (liveIndex != -1) { + // ignore: invalid_use_of_visible_for_testing_member + return liveListController.getOffsetToReveal(liveIndex, 0.5); + } - WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition()); - - return () { - scrollController.removeListener(checkPosition); - }; - }, [scrollController, controllerData, loadOlder, markRead]); + return null; + } Future jumpToId(String itemId) async { if (!scrollController.hasClients) return; - final historyIndex = split.history.indexWhere( - (item) => item.eventId == itemId, - ); - - double? offset; - if (historyIndex != -1) { - // TODO: Replace SuperSliverView because of the bug that requires this: #94 - // ignore: invalid_use_of_visible_for_testing_member - offset = historyListController.value.getOffsetToReveal( - historyIndex, - 0.5, - ); - } else { - final liveIndex = split.live.indexWhere( - (item) => item.eventId == itemId, - ); - - if (liveIndex != -1) { - // ignore: invalid_use_of_visible_for_testing_member - offset = liveListController.value.getOffsetToReveal(liveIndex, 0.5); - } - } - - if (offset == null) { - // not in current timeline - } + final offset = resolveOffset(itemId); if (offset != null) { await scrollController.animateTo( @@ -121,6 +154,27 @@ final class ChatScroll({ duration: const Duration(milliseconds: 700), curve: Curves.easeInOut, ); + return; + } + + pendingAnchorTarget.value = itemId; + setContextualEvent(itemId); + + for (var i = 0; i < 60; i++) { + if (anchorId.value == itemId) { + final context = anchorItemKeyRef.value.currentContext; + if (context != null && context.mounted) { + await Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 700), + curve: Curves.easeInOut, + ); + return; + } + } + + await Future.delayed(const Duration(milliseconds: 16)); } } @@ -128,8 +182,9 @@ final class ChatScroll({ historyItems: split.history, liveItems: split.live, centerKey: centerKey, - historyListController: historyListController.value, - liveListController: liveListController.value, + anchorItemKey: anchorItemKey, + historyListController: historyListController, + liveListController: liveListController, scrollController: scrollController, jumpToId: jumpToId, ); diff --git a/lib/widgets/room_chat/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index 3bc601c..77164c4 100644 --- a/lib/widgets/room_chat/chat_timeline.dart +++ b/lib/widgets/room_chat/chat_timeline.dart @@ -28,6 +28,7 @@ class const ChatTimeline({ required Future Function(String) jumpToId, required IList Function(Event) getEventOptions, required String? highlightedEvent, + required Key key, }) => HighlightWrapper( EventRenderer( event, @@ -35,7 +36,7 @@ class const ChatTimeline({ getEventOptions: getEventOptions, isGrouped: isGrouped(event, previousEvent), ), - key: ValueKey(event.eventId), + key: key, isHighlighted: highlightedEvent == event.eventId, ); @@ -59,6 +60,9 @@ class const ChatTimeline({ jumpToId: jumpToId, getEventOptions: getEventOptions, highlightedEvent: highlightedEvent, + key: index == 0 + ? scroll.anchorItemKey + : ValueKey(scroll.liveItems[index].eventId), ), ), @@ -72,6 +76,7 @@ class const ChatTimeline({ jumpToId: jumpToId, getEventOptions: getEventOptions, highlightedEvent: highlightedEvent, + key: ValueKey(scroll.historyItems[index].eventId), ), ), ], diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 2aee4a5..be0a606 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -74,6 +74,8 @@ final class const RoomChat({ final scroll = ChatScroll.use( controllerData: controllerData, loadOlder: notifier.loadOlder, + contextualEvent: contextualEvent.value, + setContextualEvent: (id) => contextualEvent.value = id, markRead: () async { final room = ref.read( RoomsController.provider.select((rooms) => rooms[roomId]), -- 2.55.0 From 72321cc4138cf30a972231ca5e9fd1cc4f6516db Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 25 Sep 2026 21:24:10 -0400 Subject: [PATCH 08/12] clean up approach to avoid polling --- lib/helpers/hooks/chat_scroll.dart | 51 ++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index c2d047d..f0d4f1d 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -28,8 +28,6 @@ final class ChatScroll({ final anchorId = useState(null); final anchorItemKey = useMemoized(GlobalKey.new, [anchorId.value]); - final anchorItemKeyRef = useRef(anchorItemKey); - anchorItemKeyRef.value = anchorItemKey; final historyListController = useMemoized(ListController.new, [ anchorId.value, @@ -41,6 +39,7 @@ final class ChatScroll({ final centerKey = useMemoized(GlobalKey.new); final pendingAnchorTarget = useState(null); + final anchorMountedCompleter = useRef?>(null); useEffect(() { if (anchorId.value == null) { @@ -70,13 +69,35 @@ final class ChatScroll({ false; if (found || controllerData is AsyncError) { - if (found) anchorId.value = target; + if (found) { + anchorId.value = target; + } else { + anchorMountedCompleter.value?.completeError( + StateError("Failed to load context for $target"), + ); + anchorMountedCompleter.value = null; + } pendingAnchorTarget.value = null; } return null; }, [controllerData, pendingAnchorTarget.value]); + useEffect(() { + final completer = anchorMountedCompleter.value; + if (completer == null) return null; + + WidgetsBinding.instance.addPostFrameCallback((_) { + final context = anchorItemKey.currentContext; + if (context != null && context.mounted) { + anchorMountedCompleter.value?.complete(context); + anchorMountedCompleter.value = null; + } + }); + + return null; + }, [anchorId.value]); + final ({IList history, IList live}) split = useMemoized(() { final items = controllerData.value?.timeline; final anchor = anchorId.value; @@ -157,24 +178,20 @@ final class ChatScroll({ return; } + final completer = Completer(); + anchorMountedCompleter.value = completer; pendingAnchorTarget.value = itemId; setContextualEvent(itemId); - for (var i = 0; i < 60; i++) { - if (anchorId.value == itemId) { - final context = anchorItemKeyRef.value.currentContext; - if (context != null && context.mounted) { - await Scrollable.ensureVisible( - context, - alignment: 0.5, - duration: const Duration(milliseconds: 700), - curve: Curves.easeInOut, - ); - return; - } - } + final context = await completer.future; - await Future.delayed(const Duration(milliseconds: 16)); + if (context.mounted) { + await Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 700), + curve: Curves.easeInOut, + ); } } -- 2.55.0 From 5b585737623b605b62ba5d0477f27031a22e39f6 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 26 Sep 2026 12:18:10 -0400 Subject: [PATCH 09/12] 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 { -- 2.55.0 From 6c0d7b6feda8786dccdf2860297954dd0213f389 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 26 Sep 2026 12:41:00 -0400 Subject: [PATCH 10/12] slight cleanups --- lib/controllers/room_chat.dart | 10 +++++++++- lib/helpers/hooks/chat_scroll.dart | 8 ++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 23e4afd..91b137a 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -90,8 +90,16 @@ class RoomChatController(final (String roomId, String? contextualEvent) info) ); Future paginate(Direction direction) async { - if (state.isLoading || state.value?.hasMoreBackward == false) return; + if (state.isLoading) return; + final chat = await future; + + if (direction == .forward + ? chat?.hasMoreForward == false + : chat?.hasMoreBackward == false) { + return; + } + state = .loading(); final client = ref.read(ClientController.provider.notifier); diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index 9275097..3166087 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -30,12 +30,8 @@ final class ChatScroll({ final anchorItemKey = useMemoized(GlobalKey.new, [anchorId.value]); - final historyListController = useMemoized(ListController.new, [ - anchorId.value, - ]); - final liveListController = useMemoized(ListController.new, [ - anchorId.value, - ]); + final historyListController = useMemoized(ListController.new); + final liveListController = useMemoized(ListController.new); final scrollController = useScrollController(); final centerKey = useMemoized(GlobalKey.new); -- 2.55.0 From 1d77f7195074e35c24e3f79a862c8ed2cb3f6250 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 26 Sep 2026 12:44:58 -0400 Subject: [PATCH 11/12] use dot-shorthands where possible --- lib/helpers/hooks/chat_scroll.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index 3166087..d341645 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -125,10 +125,10 @@ final class ChatScroll({ final position = scrollController.position; if (position.extentAfter <= loadThreshold) { - await paginate(Direction.backward); + await paginate(.backward); } else if (contextualEvent != null && position.extentBefore <= loadThreshold) { - await paginate(Direction.forward); + await paginate(.forward); } else if (position.extentBefore <= readThreshold) { await markRead(); } @@ -168,7 +168,7 @@ final class ChatScroll({ if (offset != null) { await scrollController.animateTo( offset, - duration: const Duration(milliseconds: 700), + duration: const .new(milliseconds: 700), curve: Curves.easeInOut, ); return; @@ -185,7 +185,7 @@ final class ChatScroll({ await Scrollable.ensureVisible( context, alignment: 0.5, - duration: const Duration(milliseconds: 700), + duration: const .new(milliseconds: 700), curve: Curves.easeInOut, ); } -- 2.55.0 From 0ee02563835f801644152ca9710bceca4138d19b Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 26 Sep 2026 13:03:00 -0400 Subject: [PATCH 12/12] add jump to bottom button --- lib/helpers/hooks/chat_scroll.dart | 80 +++++++++++++++++++--------- lib/widgets/room_chat/room_chat.dart | 18 ++++++- 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/lib/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index d341645..e1fbf9f 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -17,14 +17,15 @@ final class ChatScroll({ required final ListController historyListController, required final ListController liveListController, required final ScrollController scrollController, + required final bool atBottom, required final Future Function(String id) jumpToId, + required final Future Function() jumpToBottom, }) { factory use({ required AsyncValue controllerData, required Future Function(Direction direction) paginate, required Future Function() markRead, - required String? contextualEvent, - required void Function(String id) setContextualEvent, + required ValueNotifier contextualEvent, }) { final anchorId = useState(null); @@ -35,6 +36,8 @@ final class ChatScroll({ final scrollController = useScrollController(); final centerKey = useMemoized(GlobalKey.new); + final atBottom = useState(true); + final pendingAnchorTarget = useState(null); final anchorMountedCompleter = useRef?>(null); @@ -43,17 +46,17 @@ final class ChatScroll({ if (controllerData case AsyncData(:final value?) when value.timeline.isNotEmpty) { final hasContextualEvent = value.timeline.any( - (event) => event.eventId == contextualEvent, + (event) => event.eventId == contextualEvent.value, ); anchorId.value = hasContextualEvent - ? contextualEvent + ? contextualEvent.value : value.timeline.last.eventId; } } return null; - }, [controllerData, contextualEvent]); + }, [controllerData, contextualEvent.value]); useEffect(() { final target = pendingAnchorTarget.value; @@ -115,31 +118,43 @@ final class ChatScroll({ ); }, [controllerData, anchorId.value]); - useEffect(() { - const loadThreshold = 500.0; - const readThreshold = 50.0; + useEffect( + () { + const loadThreshold = 500.0; + const readThreshold = 50.0; - Future checkPosition() async { - if (!scrollController.hasClients) return; + Future checkPosition() async { + if (!scrollController.hasClients) return; - final position = scrollController.position; + final position = scrollController.position; - if (position.extentAfter <= loadThreshold) { - await paginate(.backward); - } else if (contextualEvent != null && - position.extentBefore <= loadThreshold) { - await paginate(.forward); - } else if (position.extentBefore <= readThreshold) { - await markRead(); + final isAtBottom = position.extentBefore <= readThreshold; + if (isAtBottom != atBottom.value) atBottom.value = isAtBottom; + + if (position.extentAfter <= loadThreshold) { + await paginate(.backward); + } else if (contextualEvent.value != null && + position.extentBefore <= loadThreshold) { + await paginate(.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, paginate, markRead, contextualEvent]); + return () => scrollController.removeListener(checkPosition); + }, + [ + scrollController, + controllerData, + paginate, + markRead, + contextualEvent.value, + ], + ); double? resolveOffset(String itemId) { final historyIndex = split.history.indexWhere( @@ -177,7 +192,7 @@ final class ChatScroll({ final completer = Completer(); anchorMountedCompleter.value = completer; pendingAnchorTarget.value = itemId; - setContextualEvent(itemId); + contextualEvent.value = itemId; final context = await completer.future; @@ -191,6 +206,21 @@ final class ChatScroll({ } } + Future jumpToBottom() async { + if (contextualEvent.value != null) { + anchorId.value = null; + contextualEvent.value = null; + } + + if (!scrollController.hasClients) return; + + await scrollController.animateTo( + scrollController.position.minScrollExtent, + duration: const .new(milliseconds: 700), + curve: Curves.easeInOut, + ); + } + return .new( historyItems: split.history, liveItems: split.live, @@ -199,7 +229,9 @@ final class ChatScroll({ historyListController: historyListController, liveListController: liveListController, scrollController: scrollController, + atBottom: atBottom.value, jumpToId: jumpToId, + jumpToBottom: jumpToBottom, ); } } diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 7648e00..5f2e196 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -74,8 +74,7 @@ final class const RoomChat({ final scroll = ChatScroll.use( controllerData: controllerData, paginate: notifier.paginate, - contextualEvent: contextualEvent.value, - setContextualEvent: (id) => contextualEvent.value = id, + contextualEvent: contextualEvent, markRead: () async { final room = ref.read( RoomsController.provider.select((rooms) => rooms[roomId]), @@ -159,6 +158,21 @@ final class const RoomChat({ ), ), ), + Positioned( + right: 16, + bottom: composerSize.value, + child: IgnorePointer( + ignoring: scroll.atBottom, + child: AnimatedOpacity( + opacity: scroll.atBottom ? 0 : 1, + duration: const Duration(milliseconds: 200), + child: FloatingActionButton.small( + onPressed: scroll.jumpToBottom, + child: const Icon(Icons.keyboard_arrow_down), + ), + ), + ), + ), Positioned( bottom: 0, left: 0, -- 2.55.0