diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 8432468..0e1059a 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -222,6 +222,9 @@ class ClientController extends AsyncNotifier { Future joinRoom(JoinRoomRequest request) async => (await _sendCommand("join_room", request.toJson()))["room_id"]; + Future knockRoom(JoinRoomRequest request) async => + (await _sendCommand("knock_room", request.toJson()))["room_id"]; + Future createRoom(CreateRoomRequest request) async => (await _sendCommand("create_room", request.toJson()))["room_id"]; diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index c0bd193..9a18f01 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -168,7 +168,7 @@ class SettingsSectionsController ), .new( title: "Hide Membership Events", - description: "Hides membership events (invites, joins, leaves, bans & knocks) from appearing in conversations.", + description: "Hides membership events (invites, joins, leaves & bans) from appearing in conversations. Knocks are still shown.", icon: Icons.person_off, builder: (title, description, icon) => SwitchListTile( title: Text(title), diff --git a/lib/helpers/extensions/join_room.dart b/lib/helpers/extensions/join_room.dart index be5d6a5..df939c1 100644 --- a/lib/helpers/extensions/join_room.dart +++ b/lib/helpers/extensions/join_room.dart @@ -40,4 +40,29 @@ extension JoinRoom on BuildContext { return roomId; } + + /// Knocks on a room, success and errors in snackbars. + /// Returns the room's ID, or null if knocking failed. + Future knockRoom( + JoinRoomRequest request, { + required String name, + }) async { + final container = ProviderScope.containerOf(this); + final messenger = ScaffoldMessenger.of(this); + + final roomId = await withProgressSnackbar( + "Knocking on $name...", + () => + container.read(ClientController.provider.notifier).knockRoom(request), + ); + if (roomId == null) return null; + + messenger.showSnackBar( + .new( + content: Text("Knocked on $name. Wait for a moderator to let you in."), + ), + ); + + return roomId; + } } diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart index b1bfeff..a56417e 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -1,14 +1,8 @@ import "package:material_ui/material_ui.dart"; import "package:nexus/helpers/extensions/show_adaptive_popover.dart"; -import "package:nexus/models/content/membership.dart"; import "package:nexus/widgets/user_bottom_sheet.dart"; extension ShowUserPopover on BuildContext { - void showUserPopover( - MembershipContent member, - String userId, { - String? roomId, - }) => showAdaptivePopover( - (context) => UserBottomSheet(member, userId, roomId: roomId), - ); + void showUserPopover(String userId, {String? roomId}) => + showAdaptivePopover((context) => UserBottomSheet(userId, roomId: roomId)); } diff --git a/lib/models/requests/join_room.dart b/lib/models/requests/join_room.dart index 22c4501..b8319cd 100644 --- a/lib/models/requests/join_room.dart +++ b/lib/models/requests/join_room.dart @@ -9,6 +9,7 @@ part "join_room.g.dart"; class const JoinRoomRequest({ required final String roomIdOrAlias, final IList via = const .empty(), + final String? reason, /// Set when accepting an invite, so gomuks can mark DMs as such. final bool fromInvite = false, diff --git a/lib/models/room_summary.dart b/lib/models/room_summary.dart index 6d22f6c..05eaf43 100644 --- a/lib/models/room_summary.dart +++ b/lib/models/room_summary.dart @@ -1,3 +1,4 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/membership.dart"; @@ -14,6 +15,9 @@ class const RoomSummary({ required final String roomId, @JsonKey(name: "num_joined_members") final int? joinedMembers, final JoinRule? joinRule, + + /// Rooms whose members may join without an invite, for restricted rooms. + final IList allowedRoomIds = const .empty(), final String? name, final Uri? avatarUrl, final String? canonicalAlias, @@ -41,6 +45,14 @@ class const RoomSummary({ /// Whether this is an invite to a direct message. @JsonKey(includeFromJson: false) final bool isDirect = false, }) with _$RoomSummary { + /// Whether the user has to knock rather than join, given the rooms they've + /// joined. Restricted knock rooms can be joined directly from an allowed room. + bool mustKnock(ISet joinedRooms) => switch (joinRule) { + .knock => true, + .knockRestricted => !allowedRoomIds.any(joinedRooms.contains), + _ => false, + }; + /// A summary of a room the user is in, from its synced metadata. factory RoomSummary.fromMetadata(RoomMetadata metadata) => .new( roomId: metadata.id, diff --git a/lib/widgets/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart index 94aec91..6ae2221 100644 --- a/lib/widgets/html/mention_chip.dart +++ b/lib/widgets/html/mention_chip.dart @@ -40,8 +40,8 @@ class const MentionChip(final String content, final String? roomId, {super.key}) ? SizedBox.shrink() : InkWell( onTap: () async { - if (data case MembershipContent membership) { - context.showUserPopover(membership, mention, roomId: roomId); + if (data is MembershipContent) { + context.showUserPopover(mention, roomId: roomId); } else if (data case RoomSummary summary) { final eventRowId = linkInfo?.eventId == null ? null diff --git a/lib/widgets/knock_sheet.dart b/lib/widgets/knock_sheet.dart new file mode 100644 index 0000000..9c6bdcb --- /dev/null +++ b/lib/widgets/knock_sheet.dart @@ -0,0 +1,51 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/join_room.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Asks the moderators of a room to let the user in, with an optional reason. +/// +/// Pops with true once the request has been sent. +class const KnockSheet( + final String roomId, { + required final String name, + final IList via = const .empty(), + super.key, +}) extends HookWidget { + @override + Widget build(BuildContext context) { + final reason = useTextEditingController(); + + void onKnock() { + context.knockRoom( + .new( + roomIdOrAlias: roomId, + via: via, + reason: reason.text.isEmpty ? null : reason.text, + ), + name: name, + ); + Navigator.of(context).pop(true); + } + + return SheetLayout( + title: Text("Knock on $name"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton(onPressed: onKnock, child: Text("Knock")), + ], + children: [ + Text( + "This room requires knocking to join. Its moderators will be able to see your knock and let you in.", + ), + TextField( + textCapitalization: .sentences, + controller: reason, + decoration: .new(labelText: "Reason (optional)"), + onEditingComplete: onKnock, + ), + ], + ); + } +} diff --git a/lib/widgets/lazy_loading/message_avatar.dart b/lib/widgets/lazy_loading/message_avatar.dart index 50b612a..0279eca 100644 --- a/lib/widgets/lazy_loading/message_avatar.dart +++ b/lib/widgets/lazy_loading/message_avatar.dart @@ -12,18 +12,17 @@ class const MessageAvatar( super.key, }) extends ConsumerWidget { @override - Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch( - AuthorController.provider(event), - )) { - AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( - onTap: () => - context.showUserPopover(value, event.sender, roomId: event.roomId), - child: AvatarOrHash( - value.avatarUrl, - value.displayName ?? event.sender.localpart, - dimension: height, - ), - ), - _ => AvatarOrHash(null, event.sender.localpart, dimension: height), - }; + Widget build(BuildContext context, WidgetRef ref) => + switch (ref.watch(AuthorController.provider(event))) { + AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( + onTap: () => + context.showUserPopover(event.sender, roomId: event.roomId), + child: AvatarOrHash( + value.avatarUrl, + value.displayName ?? event.sender.localpart, + dimension: height, + ), + ), + _ => AvatarOrHash(null, event.sender.localpart, dimension: height), + }; } diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart index 593e755..e8e4791 100644 --- a/lib/widgets/lazy_loading/message_displayname.dart +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -21,12 +21,8 @@ class const MessageDisplayname( AsyncData(:final MembershipContent? value) || AsyncLoading(:final value) || AsyncError(:final value) => InkWell( - onTap: clickable && value != null - ? () => context.showUserPopover( - value, - event.sender, - roomId: event.roomId, - ) + onTap: clickable + ? () => context.showUserPopover(event.sender, roomId: event.roomId) : null, child: switch ([ Text( diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index 85e9fcc..3084d67 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/member_list.dart @@ -25,6 +25,7 @@ class const MemberList(final String roomId, {super.key}) final options = { "Joined": .join, "Invited": .invite, + "Knocked": .knock, "Banned": .ban, }; final status = options.values.toIList()[statusIndex.value]; @@ -104,15 +105,13 @@ class const MemberList(final String roomId, {super.key}) itemBuilder: (context, index) => switch (members[index].content) { MembershipContent( - :final avatarUrl, - :final displayName, - ) && - final content => + :final avatarUrl, + :final displayName, + ) => Builder( builder: (context) => ListTile( onTap: () => context.showUserPopover( - content, members[index].stateKey!, roomId: roomId, ), diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index d70f731..d1be9dd 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -86,7 +86,12 @@ class const EventRenderer( data: (settings) => settings.hideMembershipEvents, )); - if (event.content is MembershipContent && hideMembershipEvents) { + // Knocks and their outcomes stay visible, as moderators need to act on them + if (hideMembershipEvents && + event.content is MembershipContent && + ![event.content, event.previousContent].any( + (content) => content is MembershipContent && content.status == .knock, + )) { return SizedBox.shrink(); } diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index c57200c..b1811f0 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -19,6 +19,11 @@ class const MembershipRenderer( "Make sure to only pass membership events to MembershipRenderer", ); + final previous = switch (event.previousContent) { + MembershipContent(:final status) => status, + _ => null, + }; + return switch (event.content) { MembershipContent content => GenericEventRenderer( Icons.people, @@ -28,7 +33,6 @@ class const MembershipRenderer( alignment: .middle, child: InkWell( onTap: () => context.showUserPopover( - content, event.stateKey!, roomId: event.roomId, ), @@ -43,9 +47,10 @@ class const MembershipRenderer( TextSpan( text: "${switch (content.status) { - .invite => "was invited to", + .invite => previous == .knock ? "was accepted into" : "was invited to", .join => "joined", - .leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"), + .leave when previous == .knock => event.sender == event.stateKey ? "retracted their knock on" : "had their knock denied in", + .leave => event.sender == event.stateKey ? "left" : (previous == .ban ? "was unbanned from" : "was kicked from"), .ban => "was banned from", .knock => "asked to join", }} the room${event.sender == event.stateKey ? "" : " by"}", diff --git a/lib/widgets/room_summary_sheet.dart b/lib/widgets/room_summary_sheet.dart index 60c5baa..dd7bc9b 100644 --- a/lib/widgets/room_summary_sheet.dart +++ b/lib/widgets/room_summary_sheet.dart @@ -2,12 +2,16 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/widgets/sheet_layout.dart"; +import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/invites.dart"; +import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/join_room.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/knock_sheet.dart"; import "package:nexus/widgets/linkified_text.dart"; import "package:nexus/models/room_summary.dart"; @@ -15,11 +19,17 @@ class const RoomSummarySheet( final RoomSummary summary, { final IList? via, super.key, -}) extends StatelessWidget { +}) extends ConsumerWidget { @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final name = summary.name ?? summary.canonicalAlias ?? summary.roomId; final isInvite = summary.membership == .invite; + final isKnocking = summary.membership == .knock; + final mustKnock = summary.mustKnock( + ref.watch( + RoomsController.provider.select((rooms) => rooms.keys.toISet()), + ), + ); final colorScheme = Theme.of(context).colorScheme; return SheetLayout( @@ -83,7 +93,35 @@ class const RoomSummarySheet( }, child: Text("Accept"), ), - ] else if (via != null) + ] else if (isKnocking) + TextButton( + onPressed: () { + final container = ProviderScope.containerOf(context); + Navigator.of(context).pop(); + + container + .read(ClientController.provider.notifier) + .leaveRoom(summary.roomId) + .onError(showError); + }, + style: TextButton.styleFrom(foregroundColor: colorScheme.error), + child: Text("Retract knock"), + ) + else if (via != null && mustKnock) + TextButton( + onPressed: () async { + if (await context.showAdaptiveSheet( + (_) => + KnockSheet(summary.roomId, name: name, via: via!), + ) == + true && + context.mounted) { + Navigator.of(context).pop(); + } + }, + child: Text("Knock"), + ) + else if (via != null) TextButton( onPressed: () { context.joinRoom( @@ -139,6 +177,17 @@ class const RoomSummarySheet( subtitle: LinkifiedText(summary.topic!), leading: Icon(Icons.info), ), + if (isKnocking) + ListTile( + title: Text("You've knocked"), + subtitle: Text("Waiting for a moderator to let you in"), + leading: Icon(Icons.hourglass_top), + ) + else if (mustKnock) + ListTile( + title: Text("Knock to join"), + leading: Icon(Icons.door_front_door), + ), if (summary.encryption != null) ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)), if (summary.worldReadable == true) diff --git a/lib/widgets/space_hierarchy_sheet.dart b/lib/widgets/space_hierarchy_sheet.dart index 85ae46c..33a891d 100644 --- a/lib/widgets/space_hierarchy_sheet.dart +++ b/lib/widgets/space_hierarchy_sheet.dart @@ -14,6 +14,7 @@ import "package:nexus/models/content/create.dart"; import "package:nexus/models/space_hierarchy.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/knock_sheet.dart"; import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/room_summary_sheet.dart"; import "package:nexus/widgets/sheet_layout.dart"; @@ -47,6 +48,14 @@ class const SpaceHierarchySheet(final String spaceId, {super.key}) ), child: const Text("Leave"), ) + : room.summary.mustKnock(joinedIds) + ? TextButton( + onPressed: () => context.showAdaptiveSheet( + (_) => + KnockSheet(room.summary.roomId, name: name, via: child.via), + ), + child: const Text("Knock"), + ) : TextButton( onPressed: () => context.joinRoom( .new(roomIdOrAlias: room.summary.roomId, via: child.via), diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index b6da63d..a5bb741 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -1,4 +1,5 @@ import "package:collection/collection.dart"; +import "package:dynamic_color/dynamic_color.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/widgets/sheet_layout.dart"; import "package:flutter_hooks/flutter_hooks.dart"; @@ -10,11 +11,13 @@ import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/power_level.dart"; import "package:nexus/controllers/profile.dart"; import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/user.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/create_room.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/models/content/membership.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/loading.dart"; import "package:nexus/models/membership_action.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/main.dart"; @@ -22,7 +25,6 @@ import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/start_dm_sheet.dart"; final class const UserBottomSheet( - final MembershipContent member, final String userId, { final String? roomId, super.key, @@ -35,6 +37,11 @@ final class const UserBottomSheet( color: theme.colorScheme.onSurfaceVariant, ); final client = ref.read(ClientController.provider.notifier); + // Green for accepting knocks, harmonized so it fits the current theme + final acceptScheme = ColorScheme.fromSeed( + seedColor: Colors.green.harmonizeWith(theme.colorScheme.primary), + brightness: theme.brightness, + ); void showMembershipSheet(MembershipAction action) => context.showAdaptiveSheet( @@ -82,181 +89,231 @@ final class const UserBottomSheet( ), ); - return SingleChildScrollView( - child: Padding( - padding: .all(42), - child: Column( - spacing: 4, - mainAxisSize: .min, - crossAxisAlignment: .center, - children: [ - Row( - mainAxisAlignment: .end, - children: [ - M3EButton( - onPressed: Navigator.of(context).pop, - child: Icon(Icons.close), - ), - ], - ), - SizedBox(height: 18), - - ExpandableImage( - member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), - downloadFilename: member.displayName ?? userId.localpart, - child: AvatarOrHash( - member.avatarUrl, - member.displayName ?? userId.localpart, - dimension: 200, - ), - ), - - SizedBox(height: 8), - - SelectableText( - member.displayName ?? userId.localpart, - style: textTheme.headlineLarge, - textAlign: .center, - ), - SelectableText(userId, textAlign: .center, style: subtitleStyle), - - ref - .watch(ProfileController.provider(userId)) - .betterWhen( - loading: () => Text(""), - data: (profileResponse) => Column( - children: [ - if (profileResponse.profile.timezone == null && - profileResponse.profile.pronouns.isEmpty) - Text(""), - Wrap( - crossAxisAlignment: .center, - alignment: .center, - spacing: 4, - runSpacing: 4, - children: [ - ...profileResponse.profile.pronouns - .where( - // TODO: Check system language (l10n) - (pronoun) => pronoun.language == "en", - ) - .mapIndexed( - (index, pronoun) => [ - if (index != 0) - Icon( - Icons.circle, - size: 4, - color: theme.colorScheme.onSurfaceVariant, - ), - Text( - pronoun.summary, - textAlign: .center, - style: subtitleStyle, - ), - ], - ) - .flattened, - - if (profileResponse.profile.timezone != null) ...[ - if (profileResponse.profile.pronouns.isNotEmpty) - SizedBox( - height: 16, - child: VerticalDivider( - thickness: 1.5, - width: 4, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - Text( - profileResponse.profile.timezone!, - textAlign: .center, - style: subtitleStyle, - ), - ], - ], - ), - ], - ), - ), - - SizedBox(height: 8), - if (userId != - ref.watch(ClientStateController.provider)?.userId) ...[ + return switch (ref.watch( + UserController.provider(.new(roomId: roomId, userId: userId)), + )) { + AsyncError(:final error, :final stackTrace) => ErrorDialog( + error, + stackTrace, + ), + AsyncData(value: final member) || + AsyncLoading(value: final member?) => SingleChildScrollView( + child: Padding( + padding: .all(42), + child: Column( + spacing: 4, + mainAxisSize: .min, + crossAxisAlignment: .center, + children: [ Row( + mainAxisAlignment: .end, children: [ - Expanded( - child: M3EButton.icon( - onPressed: () async { - final name = member.displayName ?? userId.localpart; - final hasDm = ref - .read(RoomsController.provider) - .values - .any((room) => room.metadata?.dmUserId == userId); - - if (hasDm) { - context.startDirectMessage(userId, name: name); - } else if (await context.showAdaptiveSheet( - (_) => StartDmSheet(userId: userId, name: name), - ) != - true) { - return; - } - - if (context.mounted) Navigator.of(context).pop(); - }, - shape: .square, - style: .tonal, - icon: Icon(Icons.message), - label: Text("Message"), - ), + M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), ), ], ), + SizedBox(height: 18), - if (roomId != null && - ref.watch( - PowerLevelController.provider( - .membershipAction( - action: .kick, - roomId: roomId!, - targetUser: userId, + ExpandableImage( + member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!), + downloadFilename: member.displayName ?? userId.localpart, + child: AvatarOrHash( + member.avatarUrl, + member.displayName ?? userId.localpart, + dimension: 200, + ), + ), + + SizedBox(height: 8), + + SelectableText( + member.displayName ?? userId.localpart, + style: textTheme.headlineLarge, + textAlign: .center, + ), + SelectableText(userId, textAlign: .center, style: subtitleStyle), + + ref + .watch(ProfileController.provider(userId)) + .betterWhen( + loading: () => Text(""), + data: (profileResponse) => Column( + children: [ + if (profileResponse.profile.timezone == null && + profileResponse.profile.pronouns.isEmpty) + Text(""), + Wrap( + crossAxisAlignment: .center, + alignment: .center, + spacing: 4, + runSpacing: 4, + children: [ + ...profileResponse.profile.pronouns + .where( + // TODO: Check system language (l10n) + (pronoun) => pronoun.language == "en", + ) + .mapIndexed( + (index, pronoun) => [ + if (index != 0) + Icon( + Icons.circle, + size: 4, + color: + theme.colorScheme.onSurfaceVariant, + ), + Text( + pronoun.summary, + textAlign: .center, + style: subtitleStyle, + ), + ], + ) + .flattened, + + if (profileResponse.profile.timezone != null) ...[ + if (profileResponse.profile.pronouns.isNotEmpty) + SizedBox( + height: 16, + child: VerticalDivider( + thickness: 1.5, + width: 4, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + profileResponse.profile.timezone!, + textAlign: .center, + style: subtitleStyle, + ), + ], + ], + ), + ], + ), + ), + + SizedBox(height: 8), + if (userId != + ref.watch(ClientStateController.provider)?.userId) ...[ + Row( + children: [ + Expanded( + child: M3EButton.icon( + onPressed: () async { + final name = member.displayName ?? userId.localpart; + final hasDm = ref + .read(RoomsController.provider) + .values + .any((room) => room.metadata?.dmUserId == userId); + + if (hasDm) { + context.startDirectMessage(userId, name: name); + } else if (await context.showAdaptiveSheet( + (_) => StartDmSheet(userId: userId, name: name), + ) != + true) { + return; + } + + if (context.mounted) Navigator.of(context).pop(); + }, + shape: .square, + style: .tonal, + icon: Icon(Icons.message), + label: Text("Message"), ), ), - )) - Padding( - padding: .only(top: 4), - child: Row( - spacing: 8, - children: [ - if (member.status == .join || member.status == .invite) - M3EButton.icon( - onPressed: () => showMembershipSheet(.kick), - shape: .square, - icon: Icon(Icons.sports_martial_arts), - label: Text("Kick"), - decoration: .styleFrom( - backgroundColor: theme.colorScheme.error, - foregroundColor: theme.colorScheme.onError, - ), - ), + ], + ), - M3EButton.icon( - onPressed: () => showMembershipSheet(.ban), - shape: .square, - icon: Icon(Icons.gavel), - label: Text("Ban"), - decoration: .styleFrom( - backgroundColor: theme.colorScheme.errorContainer, - foregroundColor: theme.colorScheme.onErrorContainer, + if (roomId != null && + member.status == .knock && + ref.watch( + PowerLevelController.provider( + .membershipAction( + action: .invite, + roomId: roomId!, + targetUser: userId, ), ), - ].map((e) => Expanded(child: e)).toList(), + )) + Padding( + padding: .only(top: 4), + child: Row( + children: [ + Expanded( + child: M3EButton.icon( + onPressed: () => client + .setMembership( + .new( + userId: userId, + roomId: roomId!, + action: .invite, + ), + ) + .onError(showError), + shape: .square, + icon: Icon(Icons.check), + label: Text("Accept knock"), + decoration: .styleFrom( + backgroundColor: acceptScheme.primary, + foregroundColor: acceptScheme.onPrimary, + ), + ), + ), + ], + ), ), - ), + + if (roomId != null && + ref.watch( + PowerLevelController.provider( + .membershipAction( + action: .kick, + roomId: roomId!, + targetUser: userId, + ), + ), + )) + Padding( + padding: .only(top: 4), + child: Row( + spacing: 8, + children: [ + if (member.status case .join || .invite || .knock) + M3EButton.icon( + onPressed: () => showMembershipSheet(.kick), + shape: .square, + icon: Icon(Icons.sports_martial_arts), + label: Text("Kick"), + decoration: .styleFrom( + backgroundColor: theme.colorScheme.error, + foregroundColor: theme.colorScheme.onError, + ), + ), + + M3EButton.icon( + onPressed: () => showMembershipSheet(.ban), + shape: .square, + icon: Icon(Icons.gavel), + label: Text("Ban"), + decoration: .styleFrom( + backgroundColor: theme.colorScheme.errorContainer, + foregroundColor: theme.colorScheme.onErrorContainer, + ), + ), + ].map((e) => Expanded(child: e)).toList(), + ), + ), + ], ], - ], + ), ), ), - ); + AsyncLoading _ => Loading(), + }; } }