add support for jumping to out-of-context events (#95)

Fixes #84.

Reviewed-on: #95
This commit is contained in:
Henry Hiles 2026-09-26 13:03:16 -04:00 • committed by Henry Hiles
commit 389b65ae61
16 changed files with 463 additions and 132 deletions

View file

@ -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<int> {
Future<Paginate> paginate(PaginateRequest request) async =>
.fromJson(await _sendCommand("paginate", request.toJson()));
Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
.fromJson(await _sendCommand("paginate_manual", request.toJson()));
Future<EventContext> getEventContext(GetEventContextRequest request) async =>
.fromJson(await _sendCommand("get_event_context", request.toJson()));
Future<ProfileResponse> getProfile(String userId) async {
try {
return .fromJson(await _sendCommand("get_profile", {"user_id": userId}));

View file

@ -7,8 +7,6 @@ typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
class NotificationsController([final NotificationsRequest? request])
extends AsyncNotifier<IList<Event>> {
static const limit = 20;
@override
Future<IList<Event>> 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,
),
);

View file

@ -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<IList<Event>?> {
class RoomChatController(final (String roomId, String? contextualEvent) info)
extends AsyncNotifier<RoomChat?> {
@override
Future<IList<Event>?> build() async {
Future<RoomChat?> 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<void> 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<void> loadOlder() async {
Future<void> 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<void> 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<RoomChatController, IList<Event>?, String>(
.autoDispose<RoomChatController, RoomChat?, (String, String?)>(
RoomChatController.new,
);
}