diff --git a/lib/controllers/pinned_events_controller.dart b/lib/controllers/pinned_events_controller.dart new file mode 100644 index 0000000..54d6196 --- /dev/null +++ b/lib/controllers/pinned_events_controller.dart @@ -0,0 +1,69 @@ +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"; +import "package:nexus/models/event.dart"; + +class PinnedEventsController extends AsyncNotifier> { + final String roomId; + PinnedEventsController(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(); + + if (pinnedStateEvent.content case PinnedEventsContent content) { + return (await Future.wait( + content.pinnedEvents.map( + (eventId) => ref.watch( + EventController.provider( + .new(eventId: eventId, roomId: roomId), + ).future, + ), + ), + )).nonNulls.toIList(); + } + + return .new(); + } + + 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>( + PinnedEventsController.new, + ); +} 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); 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/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 new file mode 100644 index 0000000..02943e9 --- /dev/null +++ b/lib/widgets/pinned_events_drawer.dart @@ -0,0 +1,85 @@ +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/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"; + +class PinnedEventsDrawer extends HookConsumerWidget { + final String roomId; + final IList Function(Event event)? getEventOptions; + const PinnedEventsDrawer(this.roomId, {this.getEventOptions, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pinsProvider = ref.watch(PinnedEventsController.provider(roomId)); + final theme = Theme.of(context); + + 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", + ), + ], + ), + 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) || + AsyncLoading(:final value?) => ListView.builder( + padding: .symmetric(horizontal: 8), + itemCount: value.length, + itemBuilder: (context, index) { + final event = value[index]; + + 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, + ), + ); + }, + ), + AsyncLoading() => Loading(), + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + }, + ), + ); + } +} diff --git a/lib/widgets/room_appbar.dart b/lib/widgets/room_appbar.dart index e1d5708..ed7b20c 100644 --- a/lib/widgets/room_appbar.dart +++ b/lib/widgets/room_appbar.dart @@ -12,13 +12,15 @@ 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) onOpenDrawer; + final void Function()? onOpenPinnedMessagesList; + final void Function() onOpenDrawer; final String? roomId; const RoomAppbar({ required this.roomId, required this.isDesktop, required this.onOpenDrawer, this.onOpenMemberList, + this.onOpenPinnedMessagesList, super.key, }); @@ -101,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 @@ -128,7 +130,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { ? .new() : .new([ IconButton( - onPressed: null, + 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 e1036cc..8f08ca9 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_events_drawer.dart"; import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/room_appbar.dart"; @@ -61,8 +62,7 @@ class RoomChat extends HookConsumerWidget { appBar: RoomAppbar( roomId: this.roomId, isDesktop: isDesktop, - onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), - onOpenMemberList: null, + onOpenDrawer: () => Scaffold.of(context).openDrawer(), ), body: nothing, ); @@ -384,131 +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; - 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 ? null : MemberList(roomId), ); } }