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
11 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(),

View file

@ -8,7 +8,7 @@ part "paginate_manual.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class const PaginateManual({
required final IList<Event> events,
final IList<Event> events = const .empty(),
final IList<Event> relatedEvents = const .empty(),
required final String? nextBatch,
}) with _$PaginateManual {

View file

@ -0,0 +1,21 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "search.freezed.dart";
part "search.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable(includeIfNull: false)
class const SearchRequest({
required final String searchTerm,
final IList<String> roomIds = const .empty(),
final int limit = 20,
final bool sortByTime = true,
final String? nextBatch,
}) with _$SearchRequest {
Map<String, Object?> toJson() => _$SearchRequestToJson(this);
factory SearchRequest.fromJson(Map<String, Object?> json) =>
_$SearchRequestFromJson(json);
}

View file

@ -1,4 +1,5 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/encryption.dart";
import "package:nexus/models/epoch_date_time_converter.dart";
import "package:nexus/models/lazy_load_summary.dart";
@ -15,6 +16,8 @@ class const RoomMetadata({
final String? topic,
final String? canonicalAlias,
final LazyLoadSummary? lazyLoadSummary,
final EncryptionContent? encryptionEvent,
required final bool hasMemberList,
@JsonKey(name: "preview_event_rowid") required final int previewEventRowID,
@EpochDateTimeConverter() required final DateTime sortingTimestamp,

View file

@ -16,6 +16,7 @@ final class const RoomAppbar({
required final void Function() onOpenDrawer,
final void Function(BuildContext context)? onOpenMemberList,
final void Function()? onOpenPinnedMessagesList,
final void Function()? onOpenSearch,
super.key,
}) extends ConsumerWidget implements PreferredSizeWidget {
@override
@ -61,6 +62,11 @@ final class const RoomAppbar({
room == null
? []
: [
IconButton(
onPressed: onOpenSearch?.call,
icon: Icon(Icons.search),
tooltip: "Search this room",
),
IconButton(
onPressed: onOpenPinnedMessagesList?.call,
icon: Icon(Icons.push_pin),

View file

@ -20,6 +20,7 @@ import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/pinned_events_drawer.dart";
import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart";
import "package:nexus/widgets/search_drawer.dart";
import "package:nexus/widgets/sync_status_banner.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/room_chat/chat_timeline.dart";
@ -198,115 +199,132 @@ final class const RoomChat({
);
return Scaffold(
endDrawer: PinnedEventsDrawer(
endDrawer: SearchDrawer(
roomId,
getEventOptions: getEventOptions,
jumpToEvent: jumpToEvent,
),
body: Builder(
builder: (middleContext) => Scaffold(
endDrawer: showMembersByDefault ? null : MemberList(roomId),
appBar: RoomAppbar(
roomId: roomId,
isDesktop: isDesktop,
onOpenDrawer: Scaffold.of(context).openDrawer,
onOpenMemberList: (thisContext) {
ref
.read(MemberListOpenedController.provider.notifier)
.set(!memberListOpened);
Scaffold.of(thisContext).openEndDrawer();
},
onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer,
builder: (searchContext) => Scaffold(
endDrawer: PinnedEventsDrawer(
roomId,
getEventOptions: getEventOptions,
jumpToEvent: jumpToEvent,
),
body: Column(
children: [
const SyncStatusBanner(),
Expanded(
child: Row(
children: [
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: .symmetric(horizontal: 4),
child: switch (controllerData) {
AsyncLoading(value: _?) ||
AsyncData(value: _) => ChatTimeline(
scroll: scroll,
roomId: roomId,
jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions,
onSwipe: swipeToReply && canReply
? (rowId) => onRelation(rowId, .reply)
: null,
highlightedEvent: highlightedEvent.value,
composerHeight: composerSize.value,
),
AsyncLoading _ => Loading(),
AsyncError(:final error, :final stackTrace) =>
ErrorDialog(error, stackTrace),
},
),
),
Positioned(
right: 16,
bottom: composerSize.value,
child: IgnorePointer(
ignoring: scroll.atBottom,
child: AnimatedOpacity(
opacity: scroll.atBottom ? 0 : 1,
duration: Durations.short4,
child: FloatingActionButton.small(
onPressed: scroll.jumpToBottom,
child: const Icon(Icons.keyboard_arrow_down),
),
),
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: MeasureSize(
onChange: (size) =>
composerSize.value = size.height,
child: Composer(
roomId,
node: composerNode,
onSend:
(
text, {
required shouldMention,
required tags,
}) => notifier
.send(
text,
tags: tags,
relationType: relationType.value,
shouldMention: shouldMention,
relatedEventRowId: relatedEvent.value,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () {
relatedEvent.value = null;
composerNode.requestFocus();
},
),
),
),
],
),
),
if (memberListOpened && showMembersByDefault)
MemberList(roomId),
],
),
body: Builder(
builder: (middleContext) => Scaffold(
endDrawer: showMembersByDefault ? null : MemberList(roomId),
appBar: RoomAppbar(
roomId: roomId,
isDesktop: isDesktop,
onOpenDrawer: Scaffold.of(context).openDrawer,
onOpenMemberList: (thisContext) {
ref
.read(MemberListOpenedController.provider.notifier)
.set(!memberListOpened);
Scaffold.of(thisContext).openEndDrawer();
},
onOpenPinnedMessagesList: Scaffold.of(middleContext)
.openEndDrawer,
onOpenSearch: Scaffold.of(searchContext).openEndDrawer,
),
],
body: Column(
children: [
const SyncStatusBanner(),
Expanded(
child: Row(
children: [
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: .symmetric(horizontal: 4),
child: switch (controllerData) {
AsyncLoading(value: _?) ||
AsyncData(value: _) => ChatTimeline(
scroll: scroll,
roomId: roomId,
jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions,
onSwipe: swipeToReply && canReply
? (rowId) => onRelation(rowId, .reply)
: null,
highlightedEvent: highlightedEvent.value,
composerHeight: composerSize.value,
),
AsyncLoading _ => Loading(),
AsyncError(
:final error,
:final stackTrace,
) =>
ErrorDialog(error, stackTrace),
},
),
),
Positioned(
right: 16,
bottom: composerSize.value,
child: IgnorePointer(
ignoring: scroll.atBottom,
child: AnimatedOpacity(
opacity: scroll.atBottom ? 0 : 1,
duration: Durations.short4,
child: FloatingActionButton.small(
onPressed: scroll.jumpToBottom,
child: const Icon(
Icons.keyboard_arrow_down,
),
),
),
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: MeasureSize(
onChange: (size) =>
composerSize.value = size.height,
child: Composer(
roomId,
node: composerNode,
onSend:
(
text, {
required shouldMention,
required tags,
}) => notifier
.send(
text,
tags: tags,
relationType: relationType.value,
shouldMention: shouldMention,
relatedEventRowId:
relatedEvent.value,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () {
relatedEvent.value = null;
composerNode.requestFocus();
},
),
),
),
],
),
),
if (memberListOpened && showMembersByDefault)
MemberList(roomId),
],
),
),
],
),
),
),
),
),

View file

@ -0,0 +1,147 @@
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/controllers/event_search.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/main.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/renderers/event.dart";
final class const SearchDrawer(
final String roomId, {
required final IList<PopupMenuEntry> Function(Event event) getEventOptions,
required final JumpToEvent jumpToEvent,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useTextEditingController();
final query = useState("");
final provider = EventSearchController.provider((
roomId: roomId,
query: query.value,
));
final results = ref.watch(provider);
final theme = Theme.of(context);
void search() {
final text = controller.text.trim();
// The provider is keyed on the query, so searching again needs a refetch
if (text == query.value) {
ref.invalidate(provider, asReload: true);
} else {
query.value = text;
}
}
Widget placeholder(IconData icon, String text) => Center(
child: Column(
mainAxisSize: .min,
children: [
Icon(icon, size: 48, color: theme.colorScheme.onSurface),
SizedBox(height: 12),
Text(text, style: theme.textTheme.headlineSmall),
],
),
);
return Drawer(
width: 400,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
scrolledUnderElevation: 0,
leading: Icon(Icons.search),
title: Text("Search"),
actionsPadding: .only(right: 4),
actions: [
IconButton(
onPressed: Scaffold.of(context).closeEndDrawer,
icon: Icon(Icons.close),
tooltip: "Close search",
),
],
),
body: Column(
children: [
Padding(
padding: .symmetric(horizontal: 12, vertical: 4),
child: TextField(
controller: controller,
autofocus: true,
textInputAction: .search,
onSubmitted: (_) => search(),
decoration: .new(
hintText: "Search this room",
prefixIcon: Icon(Icons.search),
suffixIcon: IconButton(
onPressed: search,
icon: Icon(Icons.arrow_forward),
tooltip: "Search",
),
),
),
),
Expanded(
child: query.value.isEmpty
? placeholder(Icons.search, "Search for events")
: switch (results) {
AsyncData(:final value) when value.rowIds.isEmpty =>
placeholder(Icons.search_off, "No results"),
AsyncData(:final value) ||
AsyncLoading(:final value?) => ListView.builder(
padding: .all(8),
itemCount:
value.rowIds.length +
(value.nextBatch == null ? 0 : 1),
itemBuilder: (context, index) {
if (index == value.rowIds.length) {
return Center(
child: TextButton(
onPressed: results.isLoading
? null
: () => ref
.read(provider.notifier)
.paginate()
.onError(showError),
child: Text("Load more"),
),
);
}
final rowId = value.rowIds[index];
return InkWell(
borderRadius: .circular(12),
onTap: () {
Navigator.of(context).pop();
jumpToEvent(rowId);
},
child: Padding(
padding: .symmetric(vertical: 4),
child: IgnorePointer(
child: EventRenderer(
rowId,
roomId: roomId,
maxLines: 2,
getEventOptions: getEventOptions,
),
),
),
);
},
),
AsyncLoading() => Loading(),
AsyncError(:final error, :final stackTrace) =>
ErrorDialog(error, stackTrace),
},
),
],
),
),
);
}
}