From a2d2aeca7aaa6b5010217d9cae910a439c694b3c Mon Sep 17 00:00:00 2001 From: istalri Date: Tue, 7 Jul 2026 00:54:40 +0200 Subject: [PATCH] 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), + ), ); } }