parent
427e06be3e
commit
a54004736b
11 changed files with 427 additions and 143 deletions
|
|
@ -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()));
|
||||
|
||||
|
|
|
|||
97
lib/controllers/event_search.dart
Normal file
97
lib/controllers/event_search.dart
Normal 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);
|
||||
}
|
||||
|
|
@ -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,7 +57,9 @@ class NotificationsController(final NotificationsRequest request)
|
|||
),
|
||||
);
|
||||
|
||||
storeEvents(
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.storeEvents(
|
||||
newNotifications.events.addAll(newNotifications.relatedEvents),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
21
lib/models/requests/search.dart
Normal file
21
lib/models/requests/search.dart
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,6 +199,13 @@ final class const RoomChat({
|
|||
);
|
||||
|
||||
return Scaffold(
|
||||
endDrawer: SearchDrawer(
|
||||
roomId,
|
||||
getEventOptions: getEventOptions,
|
||||
jumpToEvent: jumpToEvent,
|
||||
),
|
||||
body: Builder(
|
||||
builder: (searchContext) => Scaffold(
|
||||
endDrawer: PinnedEventsDrawer(
|
||||
roomId,
|
||||
getEventOptions: getEventOptions,
|
||||
|
|
@ -216,7 +224,9 @@ final class const RoomChat({
|
|||
.set(!memberListOpened);
|
||||
Scaffold.of(thisContext).openEndDrawer();
|
||||
},
|
||||
onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer,
|
||||
onOpenPinnedMessagesList: Scaffold.of(middleContext)
|
||||
.openEndDrawer,
|
||||
onOpenSearch: Scaffold.of(searchContext).openEndDrawer,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
|
|
@ -244,7 +254,10 @@ final class const RoomChat({
|
|||
composerHeight: composerSize.value,
|
||||
),
|
||||
AsyncLoading _ => Loading(),
|
||||
AsyncError(:final error, :final stackTrace) =>
|
||||
AsyncError(
|
||||
:final error,
|
||||
:final stackTrace,
|
||||
) =>
|
||||
ErrorDialog(error, stackTrace),
|
||||
},
|
||||
),
|
||||
|
|
@ -259,7 +272,9 @@ final class const RoomChat({
|
|||
duration: Durations.short4,
|
||||
child: FloatingActionButton.small(
|
||||
onPressed: scroll.jumpToBottom,
|
||||
child: const Icon(Icons.keyboard_arrow_down),
|
||||
child: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -285,7 +300,8 @@ final class const RoomChat({
|
|||
tags: tags,
|
||||
relationType: relationType.value,
|
||||
shouldMention: shouldMention,
|
||||
relatedEventRowId: relatedEvent.value,
|
||||
relatedEventRowId:
|
||||
relatedEvent.value,
|
||||
)
|
||||
.onError(showError),
|
||||
relationType: relationType.value,
|
||||
|
|
@ -310,6 +326,8 @@ final class const RoomChat({
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
147
lib/widgets/search_drawer.dart
Normal file
147
lib/widgets/search_drawer.dart
Normal 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),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue