From a2d2aeca7aaa6b5010217d9cae910a439c694b3c Mon Sep 17 00:00:00 2001 From: istalri Date: Tue, 7 Jul 2026 00:54:40 +0200 Subject: [PATCH 1/9] First Draft: Support for pinned messages. --- lib/controllers/room_pins_controller.dart | 92 +++++++++++++++++ lib/models/requests/send_event_request.dart | 1 + lib/widgets/pinned_messages.dart | 109 ++++++++++++++++++++ lib/widgets/room_appbar.dart | 4 +- lib/widgets/room_chat.dart | 21 +++- 5 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 lib/controllers/room_pins_controller.dart create mode 100644 lib/widgets/pinned_messages.dart diff --git a/lib/controllers/room_pins_controller.dart b/lib/controllers/room_pins_controller.dart new file mode 100644 index 0000000..7925d05 --- /dev/null +++ b/lib/controllers/room_pins_controller.dart @@ -0,0 +1,92 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/pinned_events.dart"; +import "package:nexus/models/event.dart"; + +class RoomPinsController extends AsyncNotifier> { + final String roomId; + RoomPinsController(this.roomId); + + @override + Future> build() async { + final room = ref.watch( + RoomsController.provider.select((rooms) => rooms[roomId]), + ); + + if (room == null) return .new(); + + final pinnedRowId = room.state[EventType.pinnedEvents.type]?[""]; + final pinnedStateEvent = pinnedRowId == null + ? null + : room.events[pinnedRowId]; + + if (pinnedStateEvent == null) return .new(); + + final pinnedContent = switch (pinnedStateEvent.content) { + PinnedEventsContent content => content, + _ => null, + }; + + if (pinnedContent == null || pinnedContent.pinned.isEmpty) { + return .new(); + } + + final localEvents = []; + final missingIds = []; + + for (final eventId in pinnedContent.pinned) { + final localEvent = room.events.values.firstWhereOrNull( + (e) => e.eventId == eventId, + ); + + if (localEvent != null) { + localEvents.add(localEvent); + } else { + missingIds.add(eventId); + } + } + + if (missingIds.isNotEmpty) { + final client = ref.read(ClientController.provider.notifier); + final fetched = await Future.wait( + missingIds.map( + (id) => client.getEvent(.new(roomId: roomId, eventId: id)), + ), + ); + + localEvents.addAll(fetched.whereType()); + } + + return localEvents.toIList(); + } + + Future togglePin(Event event) async { + final client = ref.read(ClientController.provider.notifier); + final current = await future; + + final updatedPinnedIds = current.any((e) => e.eventId == event.eventId) + ? current + .where((e) => e.eventId != event.eventId) + .map((e) => e.eventId) + .toList() + : [...current.map((e) => e.eventId), event.eventId]; + + await client.sendEvent( + .new( + roomId: roomId, + type: EventType.pinnedEvents.type, + stateKey: "", + content: {"pinned": updatedPinnedIds}, + ), + ); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose, String>( + RoomPinsController.new, + ); +} diff --git a/lib/models/requests/send_event_request.dart b/lib/models/requests/send_event_request.dart index da5de32..e117df2 100644 --- a/lib/models/requests/send_event_request.dart +++ b/lib/models/requests/send_event_request.dart @@ -8,6 +8,7 @@ abstract class SendEventRequest with _$SendEventRequest { required String roomId, required String type, required Map content, + @Default(null) String? stateKey, @Default(false) bool synchronous, @Default(false) bool disableEncryption, }) = _SendEventRequest; diff --git a/lib/widgets/pinned_messages.dart b/lib/widgets/pinned_messages.dart new file mode 100644 index 0000000..5c6bce7 --- /dev/null +++ b/lib/widgets/pinned_messages.dart @@ -0,0 +1,109 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/room_pins_controller.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/renderers/event.dart"; + +class PinnedMessages extends HookConsumerWidget { + final String roomId; + final IList Function(Event event)? getEventOptions; + const PinnedMessages(this.roomId, {this.getEventOptions, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pinsAsync = ref.watch(RoomPinsController.provider(roomId)); + final theme = Theme.of(context); + + return Column( + children: [ + if (Scaffold.of(context).hasEndDrawer) + AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.pin), + title: Text("Pinned Messages"), + actionsPadding: .only(right: 4), + actions: [ + IconButton( + onPressed: Scaffold.of(context).closeEndDrawer, + icon: Icon(Icons.close), + tooltip: "Close pinned messages", + ), + ], + ), + Expanded( + child: switch (pinsAsync) { + AsyncData(:final value) when value.isEmpty => Center( + child: Column( + children: [ + Icon( + Icons.push_pin_outlined, + size: 48, + color: theme.colorScheme.surfaceContainerHigh, + ), + SizedBox(height: 12), + Text( + "No pinned messages.", + style: theme.textTheme.headlineSmall, + ), + ], + ), + ), + AsyncData(:final value) => ListView.builder( + padding: .symmetric(vertical: 18), + itemCount: value.length, + itemBuilder: (context, index) { + final event = value[index]; + + return Card( + margin: .only(bottom: 8), + child: InkWell( + borderRadius: .circular(12), + onTap: () { + Navigator.pop(context); + // TODO: // Close drawer, then scroll to the message + }, + child: Padding( + padding: .all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.push_pin, + size: 16, + color: theme.colorScheme.primary, + ), + SizedBox(width: 8), + Expanded( + child: EventRenderer( + event, + textOnly: true, + maxLines: 2, + isGrouped: false, + getEventOptions: getEventOptions, + ), + ), + ], + ), + ), + ), + ); + }, + ), + AsyncLoading() => Center(child: Loading()), + AsyncError(:final error) => Center( + child: Column( + children: [ + Icon(Icons.error_outline, color: theme.colorScheme.error), + SizedBox(height: 8), + Text("$error"), + ], + ), + ), + }, + ), + ], + ); + } +} diff --git a/lib/widgets/room_appbar.dart b/lib/widgets/room_appbar.dart index e1d5708..09b159f 100644 --- a/lib/widgets/room_appbar.dart +++ b/lib/widgets/room_appbar.dart @@ -12,6 +12,7 @@ import "package:nexus/widgets/room_menu.dart"; class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { final bool isDesktop; final void Function(BuildContext context)? onOpenMemberList; + final void Function(BuildContext context)? onOpenPinnedMessagesList; final void Function(BuildContext context) onOpenDrawer; final String? roomId; const RoomAppbar({ @@ -19,6 +20,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { required this.isDesktop, required this.onOpenDrawer, this.onOpenMemberList, + this.onOpenPinnedMessagesList, super.key, }); @@ -128,7 +130,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { ? .new() : .new([ IconButton( - onPressed: null, + onPressed: () => onOpenPinnedMessagesList?.call(context), icon: Icon(Icons.push_pin), tooltip: "Open pinned messages", ), diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index e1036cc..99d3b10 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -17,6 +17,7 @@ import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/composer.dart"; import "package:nexus/widgets/emoji_picker_button.dart"; +import "package:nexus/widgets/pinned_messages.dart"; import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/room_appbar.dart"; @@ -63,6 +64,7 @@ class RoomChat extends HookConsumerWidget { isDesktop: isDesktop, onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), onOpenMemberList: null, + onOpenPinnedMessagesList: null, ), body: nothing, ); @@ -390,7 +392,15 @@ class RoomChat extends HookConsumerWidget { onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), onOpenMemberList: (thisContext) { memberListOpened.value = !memberListOpened.value; - Scaffold.of(thisContext).openEndDrawer(); + if (!showMembersByDefault) { + Scaffold.of(thisContext).openEndDrawer(); + } + }, + onOpenPinnedMessagesList: (thisContext) { + memberListOpened.value = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + Scaffold.of(thisContext).openEndDrawer(); + }); }, ), body: Row( @@ -508,7 +518,14 @@ class RoomChat extends HookConsumerWidget { MemberList(roomId), ], ), - endDrawer: showMembersByDefault ? null : MemberList(roomId), + endDrawer: showMembersByDefault && memberListOpened.value + ? null + : Drawer( + shape: Border(), + child: memberListOpened.value + ? MemberList(roomId) + : PinnedMessages(roomId, getEventOptions: getEventOptions), + ), ); } } -- 2.54.0 From 5bdf5d06b16251900184d480f9da0c99e16c718e Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 6 Jul 2026 20:07:02 -0400 Subject: [PATCH 2/9] rename RoomsPinsController to PinnedEventsController --- ...pins_controller.dart => pinned_events_controller.dart} | 8 ++++---- lib/widgets/pinned_messages.dart | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) rename lib/controllers/{room_pins_controller.dart => pinned_events_controller.dart} (92%) diff --git a/lib/controllers/room_pins_controller.dart b/lib/controllers/pinned_events_controller.dart similarity index 92% rename from lib/controllers/room_pins_controller.dart rename to lib/controllers/pinned_events_controller.dart index 7925d05..909bbfa 100644 --- a/lib/controllers/room_pins_controller.dart +++ b/lib/controllers/pinned_events_controller.dart @@ -7,9 +7,9 @@ import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/pinned_events.dart"; import "package:nexus/models/event.dart"; -class RoomPinsController extends AsyncNotifier> { +class PinnedEventsController extends AsyncNotifier> { final String roomId; - RoomPinsController(this.roomId); + PinnedEventsController(this.roomId); @override Future> build() async { @@ -86,7 +86,7 @@ class RoomPinsController extends AsyncNotifier> { } static final provider = AsyncNotifierProvider.family - .autoDispose, String>( - RoomPinsController.new, + .autoDispose, String>( + PinnedEventsController.new, ); } diff --git a/lib/widgets/pinned_messages.dart b/lib/widgets/pinned_messages.dart index 5c6bce7..67133c9 100644 --- a/lib/widgets/pinned_messages.dart +++ b/lib/widgets/pinned_messages.dart @@ -1,7 +1,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/room_pins_controller.dart"; +import "package:nexus/controllers/pinned_events_controller.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/renderers/event.dart"; @@ -13,7 +13,7 @@ class PinnedMessages extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final pinsAsync = ref.watch(RoomPinsController.provider(roomId)); + final pinsAsync = ref.watch(PinnedEventsController.provider(roomId)); final theme = Theme.of(context); return Column( -- 2.54.0 From 5c78cc85f7ac80fc68d157e77a0a5717669b0bee Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 6 Jul 2026 20:07:30 -0400 Subject: [PATCH 3/9] rename PinnedMessages to PinnedEventsDrawer --- .../{pinned_messages.dart => pinned_events_drawer.dart} | 4 ++-- lib/widgets/room_chat.dart | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) rename lib/widgets/{pinned_messages.dart => pinned_events_drawer.dart} (96%) diff --git a/lib/widgets/pinned_messages.dart b/lib/widgets/pinned_events_drawer.dart similarity index 96% rename from lib/widgets/pinned_messages.dart rename to lib/widgets/pinned_events_drawer.dart index 67133c9..e018ce8 100644 --- a/lib/widgets/pinned_messages.dart +++ b/lib/widgets/pinned_events_drawer.dart @@ -6,10 +6,10 @@ import "package:nexus/models/event.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/renderers/event.dart"; -class PinnedMessages extends HookConsumerWidget { +class PinnedEventsDrawer extends HookConsumerWidget { final String roomId; final IList Function(Event event)? getEventOptions; - const PinnedMessages(this.roomId, {this.getEventOptions, super.key}); + const PinnedEventsDrawer(this.roomId, {this.getEventOptions, super.key}); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 99d3b10..6c1715b 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -17,7 +17,7 @@ import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/composer.dart"; import "package:nexus/widgets/emoji_picker_button.dart"; -import "package:nexus/widgets/pinned_messages.dart"; +import "package:nexus/widgets/pinned_events_drawer.dart"; import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/room_appbar.dart"; @@ -524,7 +524,10 @@ class RoomChat extends HookConsumerWidget { shape: Border(), child: memberListOpened.value ? MemberList(roomId) - : PinnedMessages(roomId, getEventOptions: getEventOptions), + : PinnedEventsDrawer( + roomId, + getEventOptions: getEventOptions, + ), ), ); } -- 2.54.0 From 177c057337bdd8d4063856bf364155429417cd09 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 6 Jul 2026 20:11:19 -0400 Subject: [PATCH 4/9] cleanup PinnedEventsController --- lib/controllers/pinned_events_controller.dart | 45 +++++-------------- lib/models/content/pinned_events.dart | 5 ++- 2 files changed, 14 insertions(+), 36 deletions(-) diff --git a/lib/controllers/pinned_events_controller.dart b/lib/controllers/pinned_events_controller.dart index 909bbfa..54d6196 100644 --- a/lib/controllers/pinned_events_controller.dart +++ b/lib/controllers/pinned_events_controller.dart @@ -1,7 +1,7 @@ -import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/controllers/event_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/pinned_events.dart"; @@ -26,42 +26,19 @@ class PinnedEventsController extends AsyncNotifier> { if (pinnedStateEvent == null) return .new(); - final pinnedContent = switch (pinnedStateEvent.content) { - PinnedEventsContent content => content, - _ => null, - }; - - if (pinnedContent == null || pinnedContent.pinned.isEmpty) { - return .new(); - } - - final localEvents = []; - final missingIds = []; - - for (final eventId in pinnedContent.pinned) { - final localEvent = room.events.values.firstWhereOrNull( - (e) => e.eventId == eventId, - ); - - if (localEvent != null) { - localEvents.add(localEvent); - } else { - missingIds.add(eventId); - } - } - - if (missingIds.isNotEmpty) { - final client = ref.read(ClientController.provider.notifier); - final fetched = await Future.wait( - missingIds.map( - (id) => client.getEvent(.new(roomId: roomId, eventId: id)), + if (pinnedStateEvent.content case PinnedEventsContent content) { + return (await Future.wait( + content.pinnedEvents.map( + (eventId) => ref.watch( + EventController.provider( + .new(eventId: eventId, roomId: roomId), + ).future, + ), ), - ); - - localEvents.addAll(fetched.whereType()); + )).nonNulls.toIList(); } - return localEvents.toIList(); + return .new(); } Future togglePin(Event event) async { diff --git a/lib/models/content/pinned_events.dart b/lib/models/content/pinned_events.dart index d17a0de..8aea838 100644 --- a/lib/models/content/pinned_events.dart +++ b/lib/models/content/pinned_events.dart @@ -7,8 +7,9 @@ part "pinned_events.g.dart"; @freezed abstract class PinnedEventsContent extends Content with _$PinnedEventsContent { PinnedEventsContent._(); - factory PinnedEventsContent({@Default(IList.empty()) IList pinned}) = - _PinnedEventsContent; + factory PinnedEventsContent({ + @Default(IList.empty()) @JsonKey(name: "pinned") IList pinnedEvents, + }) = _PinnedEventsContent; factory PinnedEventsContent.fromJson(Map json) => _$PinnedEventsContentFromJson(json); -- 2.54.0 From 1f776c2a7731e0569f4088eb45d929da23fd1c2a Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 6 Jul 2026 20:36:35 -0400 Subject: [PATCH 5/9] use a double scaffold approach for pins drawer --- lib/widgets/member_list.dart | 270 +++++++++++++------------- lib/widgets/pinned_events_drawer.dart | 165 ++++++++-------- lib/widgets/room_appbar.dart | 8 +- lib/widgets/room_chat.dart | 269 +++++++++++++------------ 4 files changed, 356 insertions(+), 356 deletions(-) diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index 6326421..d910b97 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/member_list.dart @@ -33,152 +33,156 @@ class MemberList extends HookConsumerWidget { return Drawer( shape: Border(), - child: Column( - children: [ - if (Scaffold.of(context).hasEndDrawer) - AppBar( - scrolledUnderElevation: 0, - leading: Icon(Icons.people), - title: Text("Members"), - actionsPadding: .only(right: 4), - actions: [ - IconButton( - onPressed: Scaffold.of(context).closeEndDrawer, - icon: Icon(Icons.close), - tooltip: "Close member list", - ), - ], - ), - Padding( - padding: .symmetric(vertical: 8), - child: M3EToggleButtonGroup( - selectedIndex: statusIndex.value, - onSelectedIndexChanged: (index) => - statusIndex.value = index ?? statusIndex.value, - actions: options - .mapTo( - (name, value) => M3EToggleButtonGroupAction( - checkedLabel: Text( - "$name${switch (ref.watch(MembersByStatusController.provider(.new(roomId: roomId, status: value)))) { - AsyncData(:final value) || AsyncLoading(:final value?) => " (${value.length})", - _ => "", - }}", - ), - label: Text(name), - ), - ) - .toList(), - ), - ), - - switch (ref.watch( - MembersGroupedController.provider( - .new(roomId: roomId, status: status), - ), - )) { - AsyncError(:final error, :final stackTrace) => ErrorDialog( - error, - stackTrace, - ), - AsyncData(:final value) || AsyncLoading(:final value?) => - value.isEmpty - ? Center( - child: Padding( - padding: .symmetric(vertical: 18), - child: Text( - "No ${options.keys.toIList()[statusIndex.value]} Members", - style: Theme.of(context).textTheme.headlineSmall, + child: Scaffold( + backgroundColor: Colors.transparent, + body: Column( + children: [ + Padding( + padding: .symmetric(vertical: 8), + child: M3EToggleButtonGroup( + selectedIndex: statusIndex.value, + onSelectedIndexChanged: (index) => + statusIndex.value = index ?? statusIndex.value, + actions: options + .mapTo( + (name, value) => M3EToggleButtonGroupAction( + checkedLabel: Text( + "$name${switch (ref.watch(MembersByStatusController.provider(.new(roomId: roomId, status: value)))) { + AsyncData(:final value) || AsyncLoading(:final value?) => " (${value.length})", + _ => "", + }}", ), + label: Text(name), ), ) - : Expanded( - child: CustomScrollView( - slivers: [ - for (final MapEntry(key: powerLevel, value: members) - in value) ...[ - SliverToBoxAdapter( - child: Padding( - padding: .symmetric(horizontal: 16), - child: DividerText( - powerLevel == null - ? "Creators" - : "Power Level $powerLevel", + .toList(), + ), + ), + + switch (ref.watch( + MembersGroupedController.provider( + .new(roomId: roomId, status: status), + ), + )) { + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + AsyncData(:final value) || AsyncLoading(:final value?) => + value.isEmpty + ? Center( + child: Padding( + padding: .symmetric(vertical: 18), + child: Text( + "No ${options.keys.toIList()[statusIndex.value]} Members", + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ) + : Expanded( + child: CustomScrollView( + slivers: [ + for (final MapEntry(key: powerLevel, value: members) + in value) ...[ + SliverToBoxAdapter( + child: Padding( + padding: .symmetric(horizontal: 16), + child: DividerText( + powerLevel == null + ? "Creators" + : "Power Level $powerLevel", + ), ), ), - ), - SliverM3ECardList( - padding: .all(4), - color: Theme.of( - context, - ).colorScheme.surfaceContainerHigh, - margin: .symmetric(horizontal: 12, vertical: 4), - itemCount: members.length, - itemBuilder: (context, index) => - switch (members[index].content) { - MembershipContent( - :final avatarUrl, - :final displayName, - ) => - ListTile( - title: Text( - displayName ?? - members[index] + SliverM3ECardList( + padding: .all(4), + color: Theme.of( + context, + ).colorScheme.surfaceContainerHigh, + margin: .symmetric(horizontal: 12, vertical: 4), + itemCount: members.length, + itemBuilder: (context, index) => + switch (members[index].content) { + MembershipContent( + :final avatarUrl, + :final displayName, + ) => + ListTile( + title: Text( + displayName ?? + members[index] + .stateKey! + .localpart, + overflow: .ellipsis, + style: .new( + color: members[index] .stateKey! - .localpart, - overflow: .ellipsis, - style: .new( - color: members[index] - .stateKey! - .colorHash, - fontWeight: .bold, + .colorHash, + fontWeight: .bold, + ), + ), + subtitle: Text( + members[index].stateKey!, + overflow: .ellipsis, + ), + leading: AvatarOrHash( + avatarUrl, + height: 36, + displayName ?? + members[index] + .stateKey! + .localpart, ), ), - subtitle: Text( - members[index].stateKey!, - overflow: .ellipsis, - ), - leading: AvatarOrHash( - avatarUrl, - height: 36, - displayName ?? - members[index] - .stateKey! - .localpart, + _ => throw Exception( + "Member content was not MembershipContent", + ), + }, + onTap: (index) { + final member = members[index]; + if (member.content + case MembershipContent content) { + showModalBottomSheet( + constraints: BoxConstraints.loose( + Size( + 500, + (context.size?.height ?? 1000) - 80, ), ), - _ => throw Exception( - "Member content was not MembershipContent", - ), - }, - onTap: (index) { - final member = members[index]; - if (member.content - case MembershipContent content) { - showModalBottomSheet( - constraints: BoxConstraints.loose( - Size( - 500, - (context.size?.height ?? 1000) - 80, + isScrollControlled: true, + context: context, + builder: (context) => UserBottomSheet( + content, + member.stateKey!, + roomId: roomId, ), - ), - isScrollControlled: true, - context: context, - builder: (context) => UserBottomSheet( - content, - member.stateKey!, - roomId: roomId, - ), - ); - } - }, - ), + ); + } + }, + ), + ], ], - ], + ), ), - ), - AsyncLoading _ => Loading(), - }, - ], + AsyncLoading _ => Loading(), + }, + ], + ), + appBar: Scaffold.of(context).hasEndDrawer + ? AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.people), + title: Text("Members"), + actionsPadding: .only(right: 4), + actions: [ + IconButton( + onPressed: Scaffold.of(context).closeEndDrawer, + icon: Icon(Icons.close), + tooltip: "Close member list", + ), + ], + ) + : null, ), ); } diff --git a/lib/widgets/pinned_events_drawer.dart b/lib/widgets/pinned_events_drawer.dart index e018ce8..94f5ba8 100644 --- a/lib/widgets/pinned_events_drawer.dart +++ b/lib/widgets/pinned_events_drawer.dart @@ -13,97 +13,96 @@ class PinnedEventsDrawer extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final pinsAsync = ref.watch(PinnedEventsController.provider(roomId)); + final pinsProvider = ref.watch(PinnedEventsController.provider(roomId)); final theme = Theme.of(context); - return Column( - children: [ - if (Scaffold.of(context).hasEndDrawer) - AppBar( - scrolledUnderElevation: 0, - leading: Icon(Icons.pin), - title: Text("Pinned Messages"), - actionsPadding: .only(right: 4), - actions: [ - IconButton( - onPressed: Scaffold.of(context).closeEndDrawer, - icon: Icon(Icons.close), - tooltip: "Close pinned messages", - ), - ], - ), - Expanded( - child: switch (pinsAsync) { - AsyncData(:final value) when value.isEmpty => Center( - child: Column( - children: [ - Icon( - Icons.push_pin_outlined, - size: 48, - color: theme.colorScheme.surfaceContainerHigh, - ), - SizedBox(height: 12), - Text( - "No pinned messages.", - style: theme.textTheme.headlineSmall, - ), - ], - ), + return Drawer( + width: 600, + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + scrolledUnderElevation: 0, + leading: Icon(Icons.push_pin), + title: Text("Pinned Messages"), + actionsPadding: .only(right: 4), + actions: [ + IconButton( + onPressed: Scaffold.of(context).closeEndDrawer, + icon: Icon(Icons.close), + tooltip: "Close pinned messages", ), - AsyncData(:final value) => ListView.builder( - padding: .symmetric(vertical: 18), - itemCount: value.length, - itemBuilder: (context, index) { - final event = value[index]; + ], + ), + body: switch (pinsProvider) { + AsyncData(:final value) when value.isEmpty => Center( + child: Column( + children: [ + Icon( + Icons.push_pin_outlined, + size: 48, + color: theme.colorScheme.surfaceContainerHigh, + ), + SizedBox(height: 12), + Text( + "No pinned messages.", + style: theme.textTheme.headlineSmall, + ), + ], + ), + ), + AsyncData(:final value) => ListView.builder( + padding: .symmetric(vertical: 18), + itemCount: value.length, + itemBuilder: (context, index) { + final event = value[index]; - return Card( - margin: .only(bottom: 8), - child: InkWell( - borderRadius: .circular(12), - onTap: () { - Navigator.pop(context); - // TODO: // Close drawer, then scroll to the message - }, - child: Padding( - padding: .all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.push_pin, - size: 16, - color: theme.colorScheme.primary, + return Card( + margin: .only(bottom: 8), + child: InkWell( + borderRadius: .circular(12), + onTap: () { + Navigator.pop(context); + // TODO: // Close drawer, then scroll to the message + }, + child: Padding( + padding: .all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.push_pin, + size: 16, + color: theme.colorScheme.primary, + ), + SizedBox(width: 8), + Expanded( + child: EventRenderer( + event, + textOnly: true, + maxLines: 2, + isGrouped: false, + getEventOptions: getEventOptions, ), - SizedBox(width: 8), - Expanded( - child: EventRenderer( - event, - textOnly: true, - maxLines: 2, - isGrouped: false, - getEventOptions: getEventOptions, - ), - ), - ], - ), + ), + ], ), ), - ); - }, + ), + ); + }, + ), + AsyncLoading() => Center(child: Loading()), + AsyncError(:final error) => Center( + child: Column( + children: [ + Icon(Icons.error_outline, color: theme.colorScheme.error), + SizedBox(height: 8), + Text("$error"), + ], ), - AsyncLoading() => Center(child: Loading()), - AsyncError(:final error) => Center( - child: Column( - children: [ - Icon(Icons.error_outline, color: theme.colorScheme.error), - SizedBox(height: 8), - Text("$error"), - ], - ), - ), - }, - ), - ], + ), + }, + ), ); } } diff --git a/lib/widgets/room_appbar.dart b/lib/widgets/room_appbar.dart index 09b159f..ed7b20c 100644 --- a/lib/widgets/room_appbar.dart +++ b/lib/widgets/room_appbar.dart @@ -12,8 +12,8 @@ import "package:nexus/widgets/room_menu.dart"; class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { final bool isDesktop; final void Function(BuildContext context)? onOpenMemberList; - final void Function(BuildContext context)? onOpenPinnedMessagesList; - final void Function(BuildContext context) onOpenDrawer; + final void Function()? onOpenPinnedMessagesList; + final void Function() onOpenDrawer; final String? roomId; const RoomAppbar({ required this.roomId, @@ -103,7 +103,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { height: 24, fallback: Icon(Icons.numbers), ) - : DrawerButton(onPressed: () => onOpenDrawer(context)), + : DrawerButton(onPressed: onOpenDrawer), scrolledUnderElevation: 0, title: room == null ? null @@ -130,7 +130,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { ? .new() : .new([ IconButton( - onPressed: () => onOpenPinnedMessagesList?.call(context), + onPressed: onOpenPinnedMessagesList?.call, icon: Icon(Icons.push_pin), tooltip: "Open pinned messages", ), diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 6c1715b..8f08ca9 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -62,9 +62,7 @@ class RoomChat extends HookConsumerWidget { appBar: RoomAppbar( roomId: this.roomId, isDesktop: isDesktop, - onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), - onOpenMemberList: null, - onOpenPinnedMessagesList: null, + onOpenDrawer: () => Scaffold.of(context).openDrawer(), ), body: nothing, ); @@ -386,149 +384,148 @@ class RoomChat extends HookConsumerWidget { } return Scaffold( - appBar: RoomAppbar( - roomId: roomId, - isDesktop: isDesktop, - onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), - onOpenMemberList: (thisContext) { - memberListOpened.value = !memberListOpened.value; - if (!showMembersByDefault) { - Scaffold.of(thisContext).openEndDrawer(); - } - }, - onOpenPinnedMessagesList: (thisContext) { - memberListOpened.value = false; - WidgetsBinding.instance.addPostFrameCallback((_) { - Scaffold.of(thisContext).openEndDrawer(); - }); - }, - ), - body: Row( - children: [ - Expanded( - child: Stack( - children: [ - Positioned.fill( - child: Padding( - padding: .symmetric(horizontal: 4), - child: switch (controllerData) { - AsyncData(:final value?) || - AsyncLoading(:final value?) => CustomScrollView( - controller: scrollController, - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: .symmetric(vertical: 36), - child: Center( - child: ElevatedButton( - onPressed: controllerData is AsyncData - ? loadOlder - : null, - child: Text("Load More"), + endDrawer: PinnedEventsDrawer(roomId, getEventOptions: getEventOptions), + body: Builder( + builder: (middleContext) => Scaffold( + endDrawer: showMembersByDefault ? null : MemberList(roomId), + appBar: RoomAppbar( + roomId: roomId, + isDesktop: isDesktop, + onOpenDrawer: Scaffold.of(context).openDrawer, + onOpenMemberList: (thisContext) { + memberListOpened.value = !memberListOpened.value; + Scaffold.of(thisContext).openEndDrawer(); + }, + onOpenPinnedMessagesList: () { + Scaffold.of(middleContext).openEndDrawer(); + }, + ), + body: Row( + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: .symmetric(horizontal: 4), + child: switch (controllerData) { + AsyncData(:final value?) || + AsyncLoading(:final value?) => CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: .symmetric(vertical: 36), + child: Center( + child: ElevatedButton( + onPressed: controllerData is AsyncData + ? loadOlder + : null, + child: Text("Load More"), + ), + ), ), ), - ), - ), - SuperSliverList.builder( - listController: listController.value, - itemCount: value.length, - itemBuilder: (_, index) { - final event = value[index]; - final previousEvent = value.getOrNull(index - 1); - return HighlightWrapper( - EventRenderer( - event, - onTapReply: () async { - final replyId = event.replyTo; - listController.value.animateToItem( - index: value.indexWhere( - (element) => element.eventId == replyId, - ), - scrollController: scrollController, - alignment: 0.5, - duration: (_) => .new(milliseconds: 700), - curve: (_) => Curves.easeInOut, - ); - highlightedEvent.value = replyId; - await Future.delayed(.new(seconds: 1), () { - if (highlightedEvent.value == replyId) { - highlightedEvent.value = null; - } - }); - }, - getEventOptions: getEventOptions, - isGrouped: - previousEvent?.content - is MessageContent && - previousEvent?.redactedBy == null && - previousEvent?.relationType != - "m.replace" && - "${event.sender}${event.pmp?.id}" == - "${previousEvent?.sender}${previousEvent?.pmp?.id}", - ), - isHighlighted: - highlightedEvent.value == event.eventId, - ); - }, - ), + SuperSliverList.builder( + listController: listController.value, + itemCount: value.length, + itemBuilder: (_, index) { + final event = value[index]; + final previousEvent = value.getOrNull( + index - 1, + ); + return HighlightWrapper( + EventRenderer( + event, + onTapReply: () async { + final replyId = event.replyTo; + listController.value.animateToItem( + index: value.indexWhere( + (element) => + element.eventId == replyId, + ), + scrollController: scrollController, + alignment: 0.5, + duration: (_) => + .new(milliseconds: 700), + curve: (_) => Curves.easeInOut, + ); + highlightedEvent.value = replyId; + await Future.delayed( + .new(seconds: 1), + () { + if (highlightedEvent.value == + replyId) { + highlightedEvent.value = null; + } + }, + ); + }, + getEventOptions: getEventOptions, + isGrouped: + previousEvent?.content + is MessageContent && + previousEvent?.redactedBy == null && + previousEvent?.relationType != + "m.replace" && + "${event.sender}${event.pmp?.id}" == + "${previousEvent?.sender}${previousEvent?.pmp?.id}", + ), + isHighlighted: + highlightedEvent.value == event.eventId, + ); + }, + ), - SliverPadding( - padding: .only(bottom: composerSize.value), + SliverPadding( + padding: .only(bottom: composerSize.value), + ), + ], ), - ], + AsyncData() => nothing, + AsyncLoading() => Loading(), + AsyncError(:final error, :final stackTrace) => + ErrorDialog(error, stackTrace), + }, ), - AsyncData() => nothing, - AsyncLoading() => Loading(), - AsyncError(:final error, :final stackTrace) => - ErrorDialog(error, stackTrace), - }, - ), - ), - 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, - relation: relatedEvent.value, - ) - .onError(showError), - relationType: relationType.value, - relatedEvent: relatedEvent.value, - onDismiss: () => relatedEvent.value = null, ), - ), + 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, + relation: relatedEvent.value, + ) + .onError(showError), + relationType: relationType.value, + relatedEvent: relatedEvent.value, + onDismiss: () => relatedEvent.value = null, + ), + ), + ), + ], ), - ], - ), - ), + ), - if (memberListOpened.value == true && showMembersByDefault) - MemberList(roomId), - ], + if (memberListOpened.value == true && showMembersByDefault) + MemberList(roomId), + ], + ), + ), ), - endDrawer: showMembersByDefault && memberListOpened.value - ? null - : Drawer( - shape: Border(), - child: memberListOpened.value - ? MemberList(roomId) - : PinnedEventsDrawer( - roomId, - getEventOptions: getEventOptions, - ), - ), ); } } -- 2.54.0 From cf544cb8e2e1946da96e180b1807e56d23a8c673 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 6 Jul 2026 20:37:17 -0400 Subject: [PATCH 6/9] remove duplicate center --- lib/widgets/pinned_events_drawer.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/widgets/pinned_events_drawer.dart b/lib/widgets/pinned_events_drawer.dart index 94f5ba8..70e2073 100644 --- a/lib/widgets/pinned_events_drawer.dart +++ b/lib/widgets/pinned_events_drawer.dart @@ -91,7 +91,7 @@ class PinnedEventsDrawer extends HookConsumerWidget { ); }, ), - AsyncLoading() => Center(child: Loading()), + AsyncLoading() => Loading(), AsyncError(:final error) => Center( child: Column( children: [ -- 2.54.0 From 8711fc2099820538be30aeca4a0a847a6144b330 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 6 Jul 2026 20:37:26 -0400 Subject: [PATCH 7/9] use ErrorDialog to show errors --- lib/widgets/pinned_events_drawer.dart | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/widgets/pinned_events_drawer.dart b/lib/widgets/pinned_events_drawer.dart index 70e2073..97ba307 100644 --- a/lib/widgets/pinned_events_drawer.dart +++ b/lib/widgets/pinned_events_drawer.dart @@ -3,6 +3,7 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/pinned_events_controller.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"; @@ -92,14 +93,9 @@ class PinnedEventsDrawer extends HookConsumerWidget { }, ), AsyncLoading() => Loading(), - AsyncError(:final error) => Center( - child: Column( - children: [ - Icon(Icons.error_outline, color: theme.colorScheme.error), - SizedBox(height: 8), - Text("$error"), - ], - ), + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, ), }, ), -- 2.54.0 From 92f53c9acaf6da4873eb8f001db84e78344a04ed Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 6 Jul 2026 20:39:48 -0400 Subject: [PATCH 8/9] use EventRenderer card for pins --- lib/widgets/pinned_events_drawer.dart | 44 ++++++++------------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/lib/widgets/pinned_events_drawer.dart b/lib/widgets/pinned_events_drawer.dart index 97ba307..7be0913 100644 --- a/lib/widgets/pinned_events_drawer.dart +++ b/lib/widgets/pinned_events_drawer.dart @@ -52,42 +52,22 @@ class PinnedEventsDrawer extends HookConsumerWidget { ), ), AsyncData(:final value) => ListView.builder( - padding: .symmetric(vertical: 18), + padding: .symmetric(horizontal: 8), itemCount: value.length, itemBuilder: (context, index) { final event = value[index]; - return Card( - margin: .only(bottom: 8), - child: InkWell( - borderRadius: .circular(12), - onTap: () { - Navigator.pop(context); - // TODO: // Close drawer, then scroll to the message - }, - child: Padding( - padding: .all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.push_pin, - size: 16, - color: theme.colorScheme.primary, - ), - SizedBox(width: 8), - Expanded( - child: EventRenderer( - event, - textOnly: true, - maxLines: 2, - isGrouped: false, - getEventOptions: getEventOptions, - ), - ), - ], - ), - ), + return InkWell( + borderRadius: .circular(12), + onTap: () { + Navigator.pop(context); + // TODO: // Close drawer, then scroll to the message + }, + child: EventRenderer( + event, + maxLines: 2, + isGrouped: false, + getEventOptions: getEventOptions, ), ); }, -- 2.54.0 From 81bf1a8302f74093b269765b5bfdf55ac5c9a82d Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 6 Jul 2026 20:42:25 -0400 Subject: [PATCH 9/9] use stale data if needed instead of showing loading in pinned events drawer --- lib/widgets/pinned_events_drawer.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/pinned_events_drawer.dart b/lib/widgets/pinned_events_drawer.dart index 7be0913..02943e9 100644 --- a/lib/widgets/pinned_events_drawer.dart +++ b/lib/widgets/pinned_events_drawer.dart @@ -51,7 +51,8 @@ class PinnedEventsDrawer extends HookConsumerWidget { ], ), ), - AsyncData(:final value) => ListView.builder( + AsyncData(:final value) || + AsyncLoading(:final value?) => ListView.builder( padding: .symmetric(horizontal: 8), itemCount: value.length, itemBuilder: (context, index) { -- 2.54.0