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/redact_event.dart";
import "package:nexus/models/requests/register_pusher.dart"; import "package:nexus/models/requests/register_pusher.dart";
import "package:nexus/models/requests/report.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_event.dart";
import "package:nexus/models/requests/send_message.dart"; import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/requests/set_account_data.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 => Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
.fromJson(await _sendCommand("paginate_manual", request.toJson())); .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 => Future<EventContext> getEventContext(GetEventContextRequest request) async =>
.fromJson(await _sendCommand("get_event_context", request.toJson())); .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:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
@ -9,26 +8,6 @@ typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
class NotificationsController(final NotificationsRequest request) class NotificationsController(final NotificationsRequest request)
extends AsyncNotifier<IList<(int, String)>> { 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 @override
Future<IList<(int, String)>> build() async { Future<IList<(int, String)>> build() async {
final client = ref.read(ClientController.provider.notifier); 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))); return .new(mentions.events.map((event) => (event.rowId, event.roomId)));
} }
@ -76,9 +57,11 @@ class NotificationsController(final NotificationsRequest request)
), ),
); );
storeEvents( ref
newNotifications.events.addAll(newNotifications.relatedEvents), .read(RoomsController.provider.notifier)
); .storeEvents(
newNotifications.events.addAll(newNotifications.relatedEvents),
);
return currentNotifications.addAll( return currentNotifications.addAll(
newNotifications.events.map((event) => (event.rowId, event.roomId)), newNotifications.events.map((event) => (event.rowId, event.roomId)),

View file

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

View file

@ -33,6 +33,26 @@ class RoomsController extends Notifier<IMap<String, Room>> {
.new(), .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( void update(
IMap<String, Room> rooms, [ IMap<String, Room> rooms, [
ISet<String> leftRooms = const .empty(), ISet<String> leftRooms = const .empty(),

View file

@ -8,7 +8,7 @@ part "paginate_manual.g.dart";
@Freezed(toJson: false, fromJson: false) @Freezed(toJson: false, fromJson: false)
@JsonSerializable() @JsonSerializable()
class const PaginateManual({ class const PaginateManual({
required final IList<Event> events, final IList<Event> events = const .empty(),
final IList<Event> relatedEvents = const .empty(), final IList<Event> relatedEvents = const .empty(),
required final String? nextBatch, required final String? nextBatch,
}) with _$PaginateManual { }) 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: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/epoch_date_time_converter.dart";
import "package:nexus/models/lazy_load_summary.dart"; import "package:nexus/models/lazy_load_summary.dart";
@ -15,6 +16,8 @@ class const RoomMetadata({
final String? topic, final String? topic,
final String? canonicalAlias, final String? canonicalAlias,
final LazyLoadSummary? lazyLoadSummary, final LazyLoadSummary? lazyLoadSummary,
final EncryptionContent? encryptionEvent,
required final bool hasMemberList, required final bool hasMemberList,
@JsonKey(name: "preview_event_rowid") required final int previewEventRowID, @JsonKey(name: "preview_event_rowid") required final int previewEventRowID,
@EpochDateTimeConverter() required final DateTime sortingTimestamp, @EpochDateTimeConverter() required final DateTime sortingTimestamp,

View file

@ -16,6 +16,7 @@ final class const RoomAppbar({
required final void Function() onOpenDrawer, required final void Function() onOpenDrawer,
final void Function(BuildContext context)? onOpenMemberList, final void Function(BuildContext context)? onOpenMemberList,
final void Function()? onOpenPinnedMessagesList, final void Function()? onOpenPinnedMessagesList,
final void Function()? onOpenSearch,
super.key, super.key,
}) extends ConsumerWidget implements PreferredSizeWidget { }) extends ConsumerWidget implements PreferredSizeWidget {
@override @override
@ -61,6 +62,11 @@ final class const RoomAppbar({
room == null room == null
? [] ? []
: [ : [
IconButton(
onPressed: onOpenSearch?.call,
icon: Icon(Icons.search),
tooltip: "Search this room",
),
IconButton( IconButton(
onPressed: onOpenPinnedMessagesList?.call, onPressed: onOpenPinnedMessagesList?.call,
icon: Icon(Icons.push_pin), 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/pinned_events_drawer.dart";
import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.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/widgets/sync_status_banner.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/room_chat/chat_timeline.dart"; import "package:nexus/widgets/room_chat/chat_timeline.dart";
@ -198,115 +199,132 @@ final class const RoomChat({
); );
return Scaffold( return Scaffold(
endDrawer: PinnedEventsDrawer( endDrawer: SearchDrawer(
roomId, roomId,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
jumpToEvent: jumpToEvent, jumpToEvent: jumpToEvent,
), ),
body: Builder( body: Builder(
builder: (middleContext) => Scaffold( builder: (searchContext) => Scaffold(
endDrawer: showMembersByDefault ? null : MemberList(roomId), endDrawer: PinnedEventsDrawer(
appBar: RoomAppbar( roomId,
roomId: roomId, getEventOptions: getEventOptions,
isDesktop: isDesktop, jumpToEvent: jumpToEvent,
onOpenDrawer: Scaffold.of(context).openDrawer,
onOpenMemberList: (thisContext) {
ref
.read(MemberListOpenedController.provider.notifier)
.set(!memberListOpened);
Scaffold.of(thisContext).openEndDrawer();
},
onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer,
), ),
body: Column( body: Builder(
children: [ builder: (middleContext) => Scaffold(
const SyncStatusBanner(), endDrawer: showMembersByDefault ? null : MemberList(roomId),
Expanded( appBar: RoomAppbar(
child: Row( roomId: roomId,
children: [ isDesktop: isDesktop,
Expanded( onOpenDrawer: Scaffold.of(context).openDrawer,
child: Stack( onOpenMemberList: (thisContext) {
children: [ ref
Positioned.fill( .read(MemberListOpenedController.provider.notifier)
child: Padding( .set(!memberListOpened);
padding: .symmetric(horizontal: 4), Scaffold.of(thisContext).openEndDrawer();
child: switch (controllerData) { },
AsyncLoading(value: _?) || onOpenPinnedMessagesList: Scaffold.of(middleContext)
AsyncData(value: _) => ChatTimeline( .openEndDrawer,
scroll: scroll, onOpenSearch: Scaffold.of(searchContext).openEndDrawer,
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: 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),
},
),
],
),
),
);
}
}