diff --git a/commet/lib/client/components/event_search/event_search_component.dart b/commet/lib/client/components/event_search/event_search_component.dart index 1d68f5564..970dc8c9c 100644 --- a/commet/lib/client/components/event_search/event_search_component.dart +++ b/commet/lib/client/components/event_search/event_search_component.dart @@ -5,9 +5,15 @@ import 'package:commet/client/timeline_events/timeline_event.dart'; abstract class EventSearchSession { Stream> startSearch(String searchTerm); + Stream> continueSearch(); + bool get currentlySearching; + + bool get canContinueSearch; } abstract class EventSearchComponent implements Component { Future createSearchSession(Room room); + + List getSupportedSearchFeatures(Room room); } diff --git a/commet/lib/client/matrix/components/event_search/matrix_event_search_component.dart b/commet/lib/client/matrix/components/event_search/matrix_event_search_component.dart index 07fd1d16a..04b725a01 100644 --- a/commet/lib/client/matrix/components/event_search/matrix_event_search_component.dart +++ b/commet/lib/client/matrix/components/event_search/matrix_event_search_component.dart @@ -1,78 +1,48 @@ +import 'package:collection/collection.dart'; import 'package:commet/client/client.dart'; import 'package:commet/client/components/event_search/event_search_component.dart'; import 'package:commet/client/matrix/matrix_client.dart'; import 'package:commet/client/matrix/matrix_room.dart'; import 'package:commet/client/matrix/matrix_timeline.dart'; import 'package:commet/client/timeline_events/timeline_event.dart'; +import 'package:commet/debug/log.dart'; import 'package:commet/ui/molecules/timeline_events/timeline_view_entry.dart'; import 'package:commet/utils/mime.dart'; -// ignore: implementation_imports -import 'package:matrix/src/event.dart'; +import 'package:commet/utils/notifying_list.dart'; +import 'package:matrix/matrix.dart' as matrix; -class MatrixEventSearchSession extends EventSearchSession { +class MatrixEncryptedRoomEventSearchSession extends EventSearchSession { MatrixTimeline timeline; String? currentSearchTerm; - String? prevBatch; + String? lastPrevBatch; - MatrixEventSearchSession(this.timeline); + MatrixEncryptedRoomEventSearchSession(this.timeline); @override bool currentlySearching = false; - bool _requireUrl = false; - bool _requireImage = false; - bool _requireVideo = false; - bool _requireAttachment = false; - String? _requiredType; - String? _requiredSender; - - static const String hasLinkString = 'has:link'; - static const String hasImageString = 'has:image'; - static const String hasVideoString = 'has:video'; - static const String hasFileString = 'has:file'; - - List? _words; + List> results = List.empty(growable: true); @override - Stream>> startSearch(String searchTerm) async* { + Stream>> startSearch(String searchTerm, + {String? nextBatch}) async* { currentSearchTerm = searchTerm.toLowerCase(); + if (nextBatch == null) { + results = List.empty(growable: true); + } + currentlySearching = true; - _words = currentSearchTerm!.split(' '); - var typeMatch = _words!.where((w) => w.startsWith("type:")).firstOrNull; + var params = MatrixSearchParameters.parse(searchTerm); - if (typeMatch != null) { - _requiredType = typeMatch.split('type:').last; - } + var search = timeline.matrixTimeline!.startSearch( + searchTerm: searchTerm, + searchFunc: (ev) => searchFunc(params, ev), + prevBatch: nextBatch); - var userMatch = _words!.where((w) => w.startsWith("from:")).firstOrNull; - if (userMatch != null) { - _requiredSender = userMatch.split('from:').last; - } - - if (_words!.contains(hasLinkString)) _requireUrl = true; - if (_words!.contains(hasImageString)) _requireImage = true; - if (_words!.contains(hasVideoString)) _requireVideo = true; - if (_words!.contains(hasFileString)) _requireAttachment = true; - - _words = _words! - .where((w) => - [ - typeMatch, - hasLinkString, - hasImageString, - hasVideoString, - hasFileString - ].contains(w) == - false) - .toList(); - - var search = timeline.matrixTimeline! - .startSearch(searchTerm: searchTerm, searchFunc: searchFunc); - List> result = List.empty(); await for (final chunk in search) { - result = chunk.$1 + var result = chunk.$1 .map((e) => (timeline.room as MatrixRoom).convertEvent(e)) .toList(); @@ -86,67 +56,235 @@ class MatrixEventSearchSession extends EventSearchSession { } if (chunk.$2 != null) { - prevBatch = chunk.$2; + lastPrevBatch = chunk.$2; } result = m.values.toList(); result.sort((a, b) => b.originServerTs.compareTo(a.originServerTs)); - yield result; + results.addAll( + result.where((i) => !results.any((e) => i.eventId == e.eventId))); + + yield results; } currentlySearching = false; - yield result; + yield results; } - bool searchFunc(Event event) { - final numMatchingWords = _words! + bool searchFunc(MatrixSearchParameters params, matrix.Event event) { + final numMatchingWords = params.words .where((w) => event.plaintextBody.toLowerCase().contains(w)) .length; - if (_requireAttachment) { + if (params.requireAttachment) { if (event.hasAttachment == false) { return false; } } - if (_requireImage) { + if (params.requireImage) { if (!Mime.imageTypes.contains(event.attachmentMimetype)) { return false; } } - if (_requireVideo) { + if (params.requireVideo) { if (!Mime.videoTypes.contains(event.attachmentMimetype)) { return false; } } - if (_requireUrl) { + if (params.requireUrl) { if (!(event.plaintextBody.contains("https://") || event.plaintextBody.contains("http://"))) { return false; } } - if (_requiredType != null) { - if (event.type != _requiredType && event.messageType != _requiredType) { + if (params.requiredType != null) { + if (event.type != params.requiredType && + event.messageType != params.requiredType) { return false; } } - if (_requiredSender != null) { - if (event.senderId != _requiredSender) { + if (params.requiredSender != null) { + if (event.senderId != params.requiredSender) { return false; } } - if (numMatchingWords < (_words!.length.toDouble() / 2.0)) { + if (numMatchingWords < (params.words.length.toDouble() / 2.0)) { return false; } return true; } + + @override + Stream>> continueSearch() { + var token = lastPrevBatch!; + lastPrevBatch = null; + return startSearch(currentSearchTerm!, nextBatch: token); + } + + @override + bool get canContinueSearch => true; +} + +class MatrixSearchParameters { + bool requireUrl; + + bool requireImage; + + bool requireVideo; + + bool requireAttachment; + + List words; + + String? requiredSender; + + String? requiredType; + + MatrixSearchParameters({ + required this.words, + this.requireUrl = false, + this.requireImage = false, + this.requireVideo = false, + this.requireAttachment = false, + this.requiredSender, + this.requiredType, + }); + + static const String hasLinkString = 'has:link'; + static const String hasImageString = 'has:image'; + static const String hasVideoString = 'has:video'; + static const String hasFileString = 'has:file'; + + static MatrixSearchParameters parse(String query) { + var words = query.split(' '); + + var typeMatch = words.where((w) => w.startsWith("type:")).firstOrNull; + + String? requiredType; + if (typeMatch != null) { + requiredType = typeMatch.split('type:').last; + words.remove(typeMatch); + } + + String? requiredSender; + var userMatch = words.where((w) => w.startsWith("from:")).firstOrNull; + if (userMatch != null) { + requiredSender = userMatch.split('from:').last; + words.remove(userMatch); + } + + bool requireUrl = words.contains(hasLinkString); + bool requireImage = words.contains(hasImageString); + bool requireVideo = words.contains(hasVideoString); + bool requireAttachment = words.contains(hasFileString); + + words.remove(hasLinkString); + words.remove(hasFileString); + words.remove(hasImageString); + words.remove(hasVideoString); + + return MatrixSearchParameters( + words: words, + requireUrl: requireUrl, + requireImage: requireImage, + requireVideo: requireVideo, + requireAttachment: requireAttachment, + requiredSender: requiredSender, + requiredType: requiredType, + ); + } +} + +class MatrixServerEventSearchSession extends EventSearchSession { + MatrixTimeline timeline; + + MatrixServerEventSearchSession(this.timeline); + + NotifyingList> events = + NotifyingList.empty(growable: true); + + @override + bool currentlySearching = false; + + late String currentSearchTerm; + + String? nextBatchToken; + + @override + bool get canContinueSearch => nextBatchToken != null; + + @override + Stream>> continueSearch() { + return startSearch(currentSearchTerm, nextBatch: nextBatchToken!); + } + + @override + Stream>> startSearch(String searchTerm, + {String? nextBatch}) { + currentSearchTerm = searchTerm; + var client = (timeline.client as MatrixClient).matrixClient; + + var parameters = MatrixSearchParameters.parse(searchTerm); + + currentlySearching = true; + + if (nextBatch == null) { + events = NotifyingList.empty(growable: true); + } + + var criteria = matrix.RoomEventsCriteria( + searchTerm: parameters.words.join(" "), + orderBy: matrix.SearchOrder.recent, + filter: matrix.SearchFilter( + rooms: [timeline.room.identifier], + limit: 20, + senders: parameters.requiredSender != null + ? [parameters.requiredSender!] + : null, + containsUrl: parameters.requireUrl == true ? true : null), + includeState: false, + ); + + Log.i("Criteria: ${criteria.toJson()}"); + + client + .search(matrix.Categories(roomEvents: criteria), nextBatch: nextBatch) + .then((result) { + currentlySearching = false; + + var resultEvents = result.searchCategories.roomEvents?.results; + nextBatchToken = result.searchCategories.roomEvents?.nextBatch; + + events.clear(); + + if (resultEvents != null) { + events.addAll(resultEvents + .where((i) => i.result != null) + .sorted((a, b) => + b.result!.originServerTs.compareTo(a.result!.originServerTs)) + .map((i) => (timeline.room as MatrixRoom).convertEvent(matrix.Event( + content: i.result!.content, + type: i.result!.type, + eventId: i.result!.eventId, + senderId: i.result!.senderId, + originServerTs: i.result!.originServerTs, + room: timeline.matrixTimeline!.room)))); + } + + events.update(); + Log.i("Got events: ${resultEvents}"); + }); + + return events.onListUpdated.map((i) => events); + } } class MatrixEventSearchComponent implements EventSearchComponent { @@ -157,7 +295,27 @@ class MatrixEventSearchComponent implements EventSearchComponent { @override Future createSearchSession(Room room) async { - var timeline = await room.getTimeline(); - return MatrixEventSearchSession(timeline as MatrixTimeline); + if (room.isE2EE) { + var timeline = await room.getTimeline(); + return MatrixEncryptedRoomEventSearchSession(timeline as MatrixTimeline); + } else { + var timeline = await room.getTimeline(); + return MatrixServerEventSearchSession(timeline as MatrixTimeline); + } + } + + @override + List getSupportedSearchFeatures(Room room) { + if (room.isE2EE) { + return [ + MatrixSearchParameters.hasLinkString, + MatrixSearchParameters.hasFileString, + MatrixSearchParameters.hasImageString, + MatrixSearchParameters.hasVideoString, + "from:@user:example.com" + ]; + } else { + return ["from:@user:example.com"]; + } } } diff --git a/commet/lib/ui/atoms/scaled_safe_area.dart b/commet/lib/ui/atoms/scaled_safe_area.dart index f241a3288..b21dfc83a 100644 --- a/commet/lib/ui/atoms/scaled_safe_area.dart +++ b/commet/lib/ui/atoms/scaled_safe_area.dart @@ -21,8 +21,7 @@ class ScaledSafeArea extends SafeArea { padding = padding.copyWith( bottom: MediaQuery.of(context).scale().viewPadding.bottom); } - var data = MediaQuery.of(context); - print(data); + return Padding( padding: EdgeInsets.only( left: math.max(left ? padding.left : 0.0, minimum.left), diff --git a/commet/lib/ui/molecules/space_group_widget.dart b/commet/lib/ui/molecules/space_group_widget.dart index a8d68da28..596ab68dd 100644 --- a/commet/lib/ui/molecules/space_group_widget.dart +++ b/commet/lib/ui/molecules/space_group_widget.dart @@ -105,7 +105,6 @@ class _SpaceGroupWidgetState extends State { } void updateEntries() { - print("Updating group entries"); entries = widget.spaces.toList(); entries.sort((a, b) => a.order.compareTo(b.order)); diff --git a/commet/lib/ui/organisms/room_event_search/room_event_search_widget.dart b/commet/lib/ui/organisms/room_event_search/room_event_search_widget.dart index 25c7e8ffa..1ea6de2be 100644 --- a/commet/lib/ui/organisms/room_event_search/room_event_search_widget.dart +++ b/commet/lib/ui/organisms/room_event_search/room_event_search_widget.dart @@ -65,14 +65,6 @@ class _RoomEventSearchWidgetState extends State { prefix: const SizedBox( width: 10, ), - suffix: loading - ? const SizedBox( - width: 15, - height: 15, - child: CircularProgressIndicator( - strokeWidth: 2, - )) - : null, contentPadding: const EdgeInsets.fromLTRB(8, 0, 8, 0)), ), ), @@ -88,7 +80,7 @@ class _RoomEventSearchWidgetState extends State { ], ), ), - if (currentResults != null) + if (currentResults?.isNotEmpty == true) Flexible( child: ClipRect( child: ImplicitlyAnimatedList( @@ -109,7 +101,10 @@ class _RoomEventSearchWidgetState extends State { child: Padding( padding: const EdgeInsets.all(4.0), child: TimelineEventViewSingle( - room: widget.room, event: data), + room: widget.room, + event: data, + key: ValueKey("search-result_${data.eventId}"), + ), ), )), ), @@ -118,6 +113,48 @@ class _RoomEventSearchWidgetState extends State { ), ), ), + if (currentResults?.isEmpty == true && + searchSession != null && + loading == false) + Flexible( + child: Center(child: tiamat.Text.labelLow("No results found"))), + if (loading || + (currentResults?.isNotEmpty == true && + searchSession?.canContinueSearch == true)) + SizedBox( + height: 50, + child: loading + ? Center( + child: const SizedBox( + width: 15, + height: 15, + child: CircularProgressIndicator( + strokeWidth: 2, + ))) + : currentResults?.isNotEmpty == true + ? Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 4), + child: tiamat.TextButton( + "Next", + icon: Icons.search, + highlighted: true, + highlightColor: + ColorScheme.of(context).surfaceContainerLow, + onTap: () { + var stream = searchSession?.continueSearch(); + + currentSubscription?.cancel(); + currentSubscription = + stream!.listen(onResultsChanged); + + setState(() { + loading = true; + }); + }, + ), + ) + : Container(), + ) ], ); } diff --git a/commet/lib/utils/notifying_list.dart b/commet/lib/utils/notifying_list.dart index f15e36338..1d2cd6ac9 100644 --- a/commet/lib/utils/notifying_list.dart +++ b/commet/lib/utils/notifying_list.dart @@ -44,6 +44,10 @@ class NotifyingList implements INotifyingList { @override int get length => _internalList.length; + void update() { + _onListUpdated.add(null); + } + void close() { _onAdd.close(); _onRemove.close();