diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 0e1059a..96db82e 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -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 { Future paginateManual(PaginateManualRequest request) async => .fromJson(await _sendCommand("paginate_manual", request.toJson())); + Future searchLocal(SearchRequest request) async => + .fromJson(await _sendCommand("search_local", request.toJson())); + + Future searchServer(SearchRequest request) async => + .fromJson(await _sendCommand("search_server", request.toJson())); + Future getEventContext(GetEventContextRequest request) async => .fromJson(await _sendCommand("get_event_context", request.toJson())); diff --git a/lib/controllers/event_search.dart b/lib/controllers/event_search.dart new file mode 100644 index 0000000..ea78567 --- /dev/null +++ b/lib/controllers/event_search.dart @@ -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 rowIds, String? nextBatch}); + +class EventSearchController(final EventSearchParams info) + extends AsyncNotifier { + // 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 build() async { + if (info.query.trim().isEmpty) { + return (rowIds: IList(), 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(), 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 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); +} diff --git a/lib/controllers/notifications.dart b/lib/controllers/notifications.dart index 4140b66..e38ffae 100644 --- a/lib/controllers/notifications.dart +++ b/lib/controllers/notifications.dart @@ -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> { - void storeEvents(Iterable 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> 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)), diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index c4a8dda..4ed6462 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -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 = diff --git a/lib/controllers/rooms.dart b/lib/controllers/rooms.dart index 908e415..8b740c6 100644 --- a/lib/controllers/rooms.dart +++ b/lib/controllers/rooms.dart @@ -33,6 +33,26 @@ class RoomsController extends Notifier> { .new(), ); + /// Adds events to their rooms, without changing the rooms' timelines. + void storeEvents(Iterable 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 rooms, [ ISet leftRooms = const .empty(), diff --git a/lib/models/paginate_manual.dart b/lib/models/paginate_manual.dart index 752e09c..fbd3fa7 100644 --- a/lib/models/paginate_manual.dart +++ b/lib/models/paginate_manual.dart @@ -8,7 +8,7 @@ part "paginate_manual.g.dart"; @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const PaginateManual({ - required final IList events, + final IList events = const .empty(), final IList relatedEvents = const .empty(), required final String? nextBatch, }) with _$PaginateManual { diff --git a/lib/models/requests/search.dart b/lib/models/requests/search.dart new file mode 100644 index 0000000..8a19d3e --- /dev/null +++ b/lib/models/requests/search.dart @@ -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 roomIds = const .empty(), + final int limit = 20, + final bool sortByTime = true, + + final String? nextBatch, +}) with _$SearchRequest { + Map toJson() => _$SearchRequestToJson(this); + + factory SearchRequest.fromJson(Map json) => + _$SearchRequestFromJson(json); +} diff --git a/lib/models/room_metadata.dart b/lib/models/room_metadata.dart index 822fcda..7621650 100644 --- a/lib/models/room_metadata.dart +++ b/lib/models/room_metadata.dart @@ -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, diff --git a/lib/widgets/room_appbar.dart b/lib/widgets/room_appbar.dart index e284bc5..248661e 100644 --- a/lib/widgets/room_appbar.dart +++ b/lib/widgets/room_appbar.dart @@ -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), diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 155e90e..bc077e0 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -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), + ], + ), + ), + ], + ), + ), ), ), ), diff --git a/lib/widgets/search_drawer.dart b/lib/widgets/search_drawer.dart new file mode 100644 index 0000000..03418df --- /dev/null +++ b/lib/widgets/search_drawer.dart @@ -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 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), + }, + ), + ], + ), + ), + ); + } +}