From 389b65ae61bf71aebe3de407f55c8b748b1b76a1 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sat, 26 Sep 2026 13:03:16 -0400 Subject: [PATCH] add support for jumping to out-of-context events (#95) Fixes #84. Reviewed-on: https://git.federated.nexus/Nexus/nexus/pulls/95 --- lib/controllers/client.dart | 10 + lib/controllers/notifications.dart | 4 - lib/controllers/room_chat.dart | 171 +++++++++---- .../extensions/build_event_options.dart | 4 +- lib/helpers/hooks/chat_scroll.dart | 235 +++++++++++++----- lib/models/direction.dart | 8 + lib/models/event_context.dart | 22 ++ lib/models/paginate_manual.dart | 19 ++ lib/models/requests/get_event_context.dart | 17 ++ lib/models/requests/get_mentions.dart | 2 +- lib/models/requests/paginate_manual.dart | 22 ++ lib/models/room.dart | 12 +- lib/models/room_chat.dart | 32 +++ lib/widgets/reaction_row.dart | 6 +- lib/widgets/room_chat/chat_timeline.dart | 9 +- lib/widgets/room_chat/room_chat.dart | 30 ++- 16 files changed, 467 insertions(+), 136 deletions(-) create mode 100644 lib/models/direction.dart create mode 100644 lib/models/event_context.dart create mode 100644 lib/models/paginate_manual.dart create mode 100644 lib/models/requests/get_event_context.dart create mode 100644 lib/models/requests/paginate_manual.dart create mode 100644 lib/models/room_chat.dart diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 014966f..4d0c972 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -12,13 +12,16 @@ 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"; 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"; +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"; @@ -28,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"; @@ -227,6 +231,12 @@ 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())); + Future getProfile(String userId) async { try { return .fromJson(await _sendCommand("get_profile", {"user_id": userId})); 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 7b076f7..91b137a 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -10,18 +10,21 @@ 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"; 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? contextualEvent) info) + extends AsyncNotifier { @override - Future?> build() async { + 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]), ); @@ -32,7 +35,7 @@ class RoomChatController(final String roomId) await ref.read(RoomsController.provider.notifier).addState(roomId, state); } - return room.timeline + final timeline = room.timeline .toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0)) .map((element) => element.value) .toIList() @@ -54,6 +57,26 @@ class RoomChatController(final String roomId) }) .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, + hasMoreBackward: room.hasMore, + hasMoreForward: false, + ); + } else { + final context = await client.getEventContext( + .new(roomId: roomId, eventId: info.$2!), + ); + return .new( + timeline: context.before.add(context.event).addAll(context.after), + hasMoreBackward: true, + hasMoreForward: true, + historicalData: .new(start: context.start, end: context.end), + ); + } } Future deleteMessage(Event event, {String? reason}) => ref @@ -61,55 +84,101 @@ class RoomChatController(final String roomId) .redactEvent( RedactEventRequest( eventId: event.eventId, - roomId: roomId, + roomId: info.$1, reason: reason, ), ); - Future loadOlder() async { + Future paginate(Direction direction) async { if (state.isLoading) return; + final chat = await future; + + if (direction == .forward + ? chat?.hasMoreForward == false + : chat?.hasMoreBackward == false) { + return; + } + state = .loading(); - final timelineKeys = ref - .read(RoomsController.provider.select((value) => value[roomId])) - ?.timeline - .keys; - final response = await ref - .read(ClientController.provider.notifier) - .paginate( - .new( - roomId: roomId, - 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({ - roomId: 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( @@ -123,7 +192,7 @@ class RoomChatController(final String roomId) 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); } @@ -143,7 +212,7 @@ class RoomChatController(final String roomId) final client = ref.read(ClientController.provider.notifier); final event = await client.sendMessage( SendMessageRequest( - roomId: roomId, + roomId: info.$1, baseContent: baseContent, mentions: Mentions( userIds: [ @@ -165,7 +234,7 @@ class RoomChatController(final String roomId) .read(RoomsController.provider.notifier) .update( .new({ - roomId: .new( + info.$1: .new( events: .new({event.rowId: event}), clientSticky: .new({event.rowId}), ), @@ -182,7 +251,7 @@ class RoomChatController(final String roomId) final client = ref.read(ClientController.provider.notifier); final allReactionEvents = await client.getRelatedEvents( .new( - roomId: roomId, + roomId: info.$1, eventId: event.eventId, relationType: "m.annotation", ), @@ -203,7 +272,7 @@ class RoomChatController(final String roomId) if (reactionEvent != null) { await ref .watch(ClientController.provider.notifier) - .redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId)); + .redactEvent(.new(eventId: reactionEvent.eventId, roomId: info.$1)); } } @@ -212,7 +281,7 @@ class RoomChatController(final String roomId) await client.sendEvent( .new( - roomId: roomId, + roomId: info.$1, type: EventType.reaction.type, content: ReactionContent(key: reaction), synchronous: true, @@ -224,7 +293,7 @@ class RoomChatController(final String roomId) } static final provider = AsyncNotifierProvider.family - .autoDispose?, String>( + .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/helpers/hooks/chat_scroll.dart b/lib/helpers/hooks/chat_scroll.dart index efc023e..e1fbf9f 100644 --- a/lib/helpers/hooks/chat_scroll.dart +++ b/lib/helpers/hooks/chat_scroll.dart @@ -1,53 +1,112 @@ +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"; 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"; -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 GlobalKey anchorItemKey, 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, }) { - static ChatScroll use({ - required AsyncValue?> controllerData, - required String Function(T item) id, - required Future Function() loadOlder, - required Future Function() onReachedBottom, + factory use({ + required AsyncValue controllerData, + required Future Function(Direction direction) paginate, + required Future Function() markRead, + required ValueNotifier contextualEvent, }) { - final historyListController = useRef(ListController()); - final liveListController = useRef(ListController()); + final anchorId = useState(null); + + final anchorItemKey = useMemoized(GlobalKey.new, [anchorId.value]); + + final historyListController = useMemoized(ListController.new); + final liveListController = useMemoized(ListController.new); final scrollController = useScrollController(); final centerKey = useMemoized(GlobalKey.new); - final anchorId = useState(null); + final atBottom = useState(true); - final anchorIdValue = anchorId.value; + final pendingAnchorTarget = useState(null); + final anchorMountedCompleter = useRef?>(null); useEffect(() { if (anchorId.value == null) { if (controllerData case AsyncData(:final value?) - when value.isNotEmpty) { - anchorId.value = id(value.last); + when value.timeline.isNotEmpty) { + final hasContextualEvent = value.timeline.any( + (event) => event.eventId == contextualEvent.value, + ); + + anchorId.value = hasContextualEvent + ? contextualEvent.value + : value.timeline.last.eventId; } } return null; - }, [controllerData]); + }, [controllerData, contextualEvent.value]); - final ({IList history, IList live}) split = useMemoized(() { - final items = controllerData.value; - final anchor = anchorIdValue; + 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; + } 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; 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); @@ -57,80 +116,122 @@ 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 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 <= topThreshold) { - await loadOlder(); - } else if (position.extentBefore <= bottomThreshold) { - await onReachedBottom(); + 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); + + WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition()); + + return () => scrollController.removeListener(checkPosition); + }, + [ + scrollController, + controllerData, + paginate, + markRead, + contextualEvent.value, + ], + ); + + 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, onReachedBottom]); + return null; + } Future jumpToId(String itemId) async { if (!scrollController.hasClients) return; - final historyIndex = split.history.indexWhere( - (item) => id(item) == itemId, - ); - - 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( - historyIndex, - 0.5, - ); + final offset = resolveOffset(itemId); + if (offset != null) { await scrollController.animateTo( offset, - duration: const Duration(milliseconds: 700), + duration: const .new(milliseconds: 700), curve: Curves.easeInOut, ); - } else { - final liveIndex = split.live.indexWhere((item) => id(item) == itemId); - - 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, - ); - } + return; } + + final completer = Completer(); + anchorMountedCompleter.value = completer; + pendingAnchorTarget.value = itemId; + contextualEvent.value = itemId; + + final context = await completer.future; + + if (context.mounted) { + await Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const .new(milliseconds: 700), + curve: Curves.easeInOut, + ); + } + } + + 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, centerKey: centerKey, - historyListController: historyListController.value, - liveListController: liveListController.value, + anchorItemKey: anchorItemKey, + historyListController: historyListController, + liveListController: liveListController, scrollController: scrollController, + atBottom: atBottom.value, jumpToId: jumpToId, + jumpToBottom: jumpToBottom, ); } } 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/event_context.dart b/lib/models/event_context.dart new file mode 100644 index 0000000..6103c7d --- /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 Event event, + 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/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 new file mode 100644 index 0000000..68f23a0 --- /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, + final int limit = 20, +}) with _$GetEventContextRequest { + Map toJson() => _$GetEventContextRequestToJson(this); + + factory GetEventContextRequest.fromJson(Map json) => + _$GetEventContextRequestFromJson(json); +} 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.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 new file mode 100644 index 0000000..a038a88 --- /dev/null +++ b/lib/models/room_chat.dart @@ -0,0 +1,32 @@ +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 timeline, + required final bool hasMoreForward, + required final bool hasMoreBackward, + 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 start, + required final String end, +}) with _$HistoricalData { + Map toJson() => _$HistoricalDataToJson(this); + + factory HistoricalData.fromJson(Map json) => + _$HistoricalDataFromJson(json); +} 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/chat_timeline.dart b/lib/widgets/room_chat/chat_timeline.dart index 434145c..77164c4 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, @@ -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 951d9e3..5f2e196 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -24,13 +24,15 @@ 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 contextualEvent = useState(initialHighlightedEvent); + final highlightedEvent = useState(initialHighlightedEvent); final composerSize = useState(64); @@ -59,7 +61,10 @@ final class const RoomChat({ final roomId = this.roomId!; - final controllerProvider = RoomChatController.provider(roomId); + final controllerProvider = RoomChatController.provider(( + roomId, + contextualEvent.value, + )); final notifier = ref.watch(controllerProvider.notifier); final client = ref.read(ClientController.provider.notifier); @@ -68,9 +73,9 @@ final class const RoomChat({ final scroll = ChatScroll.use( controllerData: controllerData, - id: (event) => event.eventId, - loadOlder: notifier.loadOlder, - onReachedBottom: () async { + paginate: notifier.paginate, + contextualEvent: contextualEvent, + markRead: () async { final room = ref.read( RoomsController.provider.select((rooms) => rooms[roomId]), ); @@ -153,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,