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
3 changed files with 427 additions and 143 deletions

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),
},
),
],
),
),
);
}
}