add event search

Fixes #77
This commit is contained in:
Henry Hiles 2026-10-21 13:31:01 -04:00
commit a54004736b
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
5 changed files with 427 additions and 143 deletions

View file

@ -41,6 +41,7 @@ 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";
import "package:nexus/models/requests/search.dart";
import "package:nexus/models/requests/send_event.dart";
import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/requests/set_account_data.dart";
@ -276,6 +277,12 @@ class ClientController extends AsyncNotifier<int> {
Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
.fromJson(await _sendCommand("paginate_manual", request.toJson()));
Future<PaginateManual> searchLocal(SearchRequest request) async =>
.fromJson(await _sendCommand("search_local", request.toJson()));
Future<PaginateManual> searchServer(SearchRequest request) async =>
.fromJson(await _sendCommand("search_server", request.toJson()));
Future<EventContext> getEventContext(GetEventContextRequest request) async =>
.fromJson(await _sendCommand("get_event_context", request.toJson()));

View file

@ -0,0 +1,97 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/requests/search.dart";
typedef EventSearchParams = ({String roomId, String query});
typedef EventSearchResults = ({IList<int> rowIds, String? nextBatch});
class EventSearchController(final EventSearchParams info)
extends AsyncNotifier<EventSearchResults> {
// We could have a mode without this for power users in the future
static String toFtsQuery(String query) => query
.split(RegExp(r"\s+"))
.where((word) => word.isNotEmpty)
.map((word) => '"${word.replaceAll('"', '""')}"*')
.join(" ");
@override
Future<EventSearchResults> build() async {
if (info.query.trim().isEmpty) {
return (rowIds: IList<int>(), nextBatch: null);
}
final client = ref.read(ClientController.provider.notifier);
final encrypted = ref.read(
RoomsController.provider.select(
(rooms) => rooms[info.roomId]?.metadata?.encryptionEvent != null,
),
);
final request = SearchRequest(
searchTerm: encrypted ? toFtsQuery(info.query) : info.query,
roomIds: .new([info.roomId]),
);
final response = encrypted
? await client.searchLocal(request)
: await client.searchServer(request);
if (!ref.mounted) return (rowIds: IList<int>(), nextBatch: null);
ref
.read(RoomsController.provider.notifier)
.storeEvents(response.events.addAll(response.relatedEvents));
return (
rowIds: response.events.map((event) => event.rowId).toIList(),
nextBatch: response.nextBatch,
);
}
Future<void> paginate() async {
if (state.isLoading) return;
final results = await future;
if (!ref.mounted || results.nextBatch == null) return;
state = .loading();
final encrypted = ref.read(
RoomsController.provider.select(
(rooms) => rooms[info.roomId]?.metadata?.encryptionEvent != null,
),
);
final request = SearchRequest(
searchTerm: encrypted ? toFtsQuery(info.query) : info.query,
roomIds: .new([info.roomId]),
nextBatch: results.nextBatch,
);
final client = ref.read(ClientController.provider.notifier);
final response = encrypted
? await client.searchLocal(request)
: await client.searchServer(request);
if (!ref.mounted) return;
ref
.read(RoomsController.provider.notifier)
.storeEvents(response.events.addAll(response.relatedEvents));
state = .data((
rowIds: results.rowIds.addAll(
response.events.map((event) => event.rowId),
),
nextBatch: response.nextBatch,
));
}
static final provider = AsyncNotifierProvider.family
.autoDispose<
EventSearchController,
EventSearchResults,
EventSearchParams
>(EventSearchController.new);
}

View file

@ -1,4 +1,3 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
@ -9,26 +8,6 @@ typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
class NotificationsController(final NotificationsRequest request)
extends AsyncNotifier<IList<(int, String)>> {
void storeEvents(Iterable<Event> events) => ref
.read(RoomsController.provider.notifier)
.update(
.new(
events
.groupListsBy((event) => event.roomId)
.map(
(roomId, roomEvents) => .new(
roomId,
.new(
events: IMap.fromIterable(
roomEvents,
keyMapper: (event) => event.rowId,
),
),
),
),
),
);
@override
Future<IList<(int, String)>> build() async {
final client = ref.read(ClientController.provider.notifier);
@ -43,7 +22,9 @@ class NotificationsController(final NotificationsRequest request)
),
);
storeEvents(mentions.events.addAll(mentions.relatedEvents));
ref
.read(RoomsController.provider.notifier)
.storeEvents(mentions.events.addAll(mentions.relatedEvents));
return .new(mentions.events.map((event) => (event.rowId, event.roomId)));
}
@ -76,9 +57,11 @@ class NotificationsController(final NotificationsRequest request)
),
);
storeEvents(
newNotifications.events.addAll(newNotifications.relatedEvents),
);
ref
.read(RoomsController.provider.notifier)
.storeEvents(
newNotifications.events.addAll(newNotifications.relatedEvents),
);
return currentNotifications.addAll(
newNotifications.events.map((event) => (event.rowId, event.roomId)),

View file

@ -73,16 +73,7 @@ class RoomChatController(final ChatParams info)
.addAll(context.after);
ref
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: Room(
events: IMap.fromIterable(
events.addAll(context.relatedEvents),
keyMapper: (event) => event.rowId,
),
),
}),
);
.storeEvents(events.addAll(context.relatedEvents));
return .new(
timeline: .new(events.map((element) => element.rowId)),
hasMoreBackward: true,
@ -186,17 +177,8 @@ class RoomChatController(final ChatParams info)
ref
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: Room(
events: IMap.fromIterable(
paginationResponse.events.addAll(
paginationResponse.relatedEvents,
),
keyMapper: (event) => event.rowId,
),
),
}),
.storeEvents(
paginationResponse.events.addAll(paginationResponse.relatedEvents),
);
final eventRowIds =

View file

@ -33,6 +33,26 @@ class RoomsController extends Notifier<IMap<String, Room>> {
.new(),
);
/// Adds events to their rooms, without changing the rooms' timelines.
void storeEvents(Iterable<Event> events) => update(
.new(
events
.groupListsBy((event) => event.roomId)
.map(
(roomId, roomEvents) => .new(
roomId,
Room(
// ignore: unused_result - I am using it you donkey
events: .fromIterable(
roomEvents,
keyMapper: (event) => event.rowId,
),
),
),
),
),
);
void update(
IMap<String, Room> rooms, [
ISet<String> leftRooms = const .empty(),