add knock support

Fixes #123
This commit is contained in:
Henry Hiles 2026-10-21 12:32:02 -04:00
commit 427e06be3e
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
16 changed files with 399 additions and 194 deletions

View file

@ -222,6 +222,9 @@ class ClientController extends AsyncNotifier<int> {
Future<String> joinRoom(JoinRoomRequest request) async => Future<String> joinRoom(JoinRoomRequest request) async =>
(await _sendCommand("join_room", request.toJson()))["room_id"]; (await _sendCommand("join_room", request.toJson()))["room_id"];
Future<String> knockRoom(JoinRoomRequest request) async =>
(await _sendCommand("knock_room", request.toJson()))["room_id"];
Future<String> createRoom(CreateRoomRequest request) async => Future<String> createRoom(CreateRoomRequest request) async =>
(await _sendCommand("create_room", request.toJson()))["room_id"]; (await _sendCommand("create_room", request.toJson()))["room_id"];

View file

@ -168,7 +168,7 @@ class SettingsSectionsController
), ),
.new( .new(
title: "Hide Membership Events", 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, icon: Icons.person_off,
builder: (title, description, icon) => SwitchListTile( builder: (title, description, icon) => SwitchListTile(
title: Text(title), title: Text(title),

View file

@ -40,4 +40,29 @@ extension JoinRoom on BuildContext {
return roomId; return roomId;
} }
/// Knocks on a room, success and errors in snackbars.
/// Returns the room's ID, or null if knocking failed.
Future<String?> 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;
}
} }

View file

@ -1,14 +1,8 @@
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/show_adaptive_popover.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"; import "package:nexus/widgets/user_bottom_sheet.dart";
extension ShowUserPopover on BuildContext { extension ShowUserPopover on BuildContext {
void showUserPopover( void showUserPopover(String userId, {String? roomId}) =>
MembershipContent member, showAdaptivePopover((context) => UserBottomSheet(userId, roomId: roomId));
String userId, {
String? roomId,
}) => showAdaptivePopover(
(context) => UserBottomSheet(member, userId, roomId: roomId),
);
} }

View file

@ -9,6 +9,7 @@ part "join_room.g.dart";
class const JoinRoomRequest({ class const JoinRoomRequest({
required final String roomIdOrAlias, required final String roomIdOrAlias,
final IList<String> via = const .empty(), final IList<String> via = const .empty(),
final String? reason,
/// Set when accepting an invite, so gomuks can mark DMs as such. /// Set when accepting an invite, so gomuks can mark DMs as such.
final bool fromInvite = false, final bool fromInvite = false,

View file

@ -1,3 +1,4 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
@ -14,6 +15,9 @@ class const RoomSummary({
required final String roomId, required final String roomId,
@JsonKey(name: "num_joined_members") final int? joinedMembers, @JsonKey(name: "num_joined_members") final int? joinedMembers,
final JoinRule? joinRule, final JoinRule? joinRule,
/// Rooms whose members may join without an invite, for restricted rooms.
final IList<String> allowedRoomIds = const .empty(),
final String? name, final String? name,
final Uri? avatarUrl, final Uri? avatarUrl,
final String? canonicalAlias, final String? canonicalAlias,
@ -41,6 +45,14 @@ class const RoomSummary({
/// Whether this is an invite to a direct message. /// Whether this is an invite to a direct message.
@JsonKey(includeFromJson: false) final bool isDirect = false, @JsonKey(includeFromJson: false) final bool isDirect = false,
}) with _$RoomSummary { }) 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<String> joinedRooms) => switch (joinRule) {
.knock => true,
.knockRestricted => !allowedRoomIds.any(joinedRooms.contains),
_ => false,
};
/// A summary of a room the user is in, from its synced metadata. /// A summary of a room the user is in, from its synced metadata.
factory RoomSummary.fromMetadata(RoomMetadata metadata) => .new( factory RoomSummary.fromMetadata(RoomMetadata metadata) => .new(
roomId: metadata.id, roomId: metadata.id,

View file

@ -40,8 +40,8 @@ class const MentionChip(final String content, final String? roomId, {super.key})
? SizedBox.shrink() ? SizedBox.shrink()
: InkWell( : InkWell(
onTap: () async { onTap: () async {
if (data case MembershipContent membership) { if (data is MembershipContent) {
context.showUserPopover(membership, mention, roomId: roomId); context.showUserPopover(mention, roomId: roomId);
} else if (data case RoomSummary summary) { } else if (data case RoomSummary summary) {
final eventRowId = linkInfo?.eventId == null final eventRowId = linkInfo?.eventId == null
? null ? null

View file

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

View file

@ -12,18 +12,17 @@ class const MessageAvatar(
super.key, super.key,
}) extends ConsumerWidget { }) extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch( Widget build(BuildContext context, WidgetRef ref) =>
AuthorController.provider(event), switch (ref.watch(AuthorController.provider(event))) {
)) { AsyncData(:final value) || AsyncLoading(:final value?) => InkWell(
AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( onTap: () =>
onTap: () => context.showUserPopover(event.sender, roomId: event.roomId),
context.showUserPopover(value, event.sender, roomId: event.roomId), child: AvatarOrHash(
child: AvatarOrHash( value.avatarUrl,
value.avatarUrl, value.displayName ?? event.sender.localpart,
value.displayName ?? event.sender.localpart, dimension: height,
dimension: height, ),
), ),
), _ => AvatarOrHash(null, event.sender.localpart, dimension: height),
_ => AvatarOrHash(null, event.sender.localpart, dimension: height), };
};
} }

View file

@ -21,12 +21,8 @@ class const MessageDisplayname(
AsyncData(:final MembershipContent? value) || AsyncData(:final MembershipContent? value) ||
AsyncLoading(:final value) || AsyncLoading(:final value) ||
AsyncError(:final value) => InkWell( AsyncError(:final value) => InkWell(
onTap: clickable && value != null onTap: clickable
? () => context.showUserPopover( ? () => context.showUserPopover(event.sender, roomId: event.roomId)
value,
event.sender,
roomId: event.roomId,
)
: null, : null,
child: switch ([ child: switch ([
Text( Text(

View file

@ -25,6 +25,7 @@ class const MemberList(final String roomId, {super.key})
final options = <String, MembershipStatus>{ final options = <String, MembershipStatus>{
"Joined": .join, "Joined": .join,
"Invited": .invite, "Invited": .invite,
"Knocked": .knock,
"Banned": .ban, "Banned": .ban,
}; };
final status = options.values.toIList()[statusIndex.value]; final status = options.values.toIList()[statusIndex.value];
@ -104,15 +105,13 @@ class const MemberList(final String roomId, {super.key})
itemBuilder: (context, index) => itemBuilder: (context, index) =>
switch (members[index].content) { switch (members[index].content) {
MembershipContent( MembershipContent(
:final avatarUrl, :final avatarUrl,
:final displayName, :final displayName,
) && ) =>
final content =>
Builder( Builder(
builder: (context) => ListTile( builder: (context) => ListTile(
onTap: () => onTap: () =>
context.showUserPopover( context.showUserPopover(
content,
members[index].stateKey!, members[index].stateKey!,
roomId: roomId, roomId: roomId,
), ),

View file

@ -86,7 +86,12 @@ class const EventRenderer(
data: (settings) => settings.hideMembershipEvents, 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(); return SizedBox.shrink();
} }

View file

@ -19,6 +19,11 @@ class const MembershipRenderer(
"Make sure to only pass membership events to MembershipRenderer", "Make sure to only pass membership events to MembershipRenderer",
); );
final previous = switch (event.previousContent) {
MembershipContent(:final status) => status,
_ => null,
};
return switch (event.content) { return switch (event.content) {
MembershipContent content => GenericEventRenderer( MembershipContent content => GenericEventRenderer(
Icons.people, Icons.people,
@ -28,7 +33,6 @@ class const MembershipRenderer(
alignment: .middle, alignment: .middle,
child: InkWell( child: InkWell(
onTap: () => context.showUserPopover( onTap: () => context.showUserPopover(
content,
event.stateKey!, event.stateKey!,
roomId: event.roomId, roomId: event.roomId,
), ),
@ -43,9 +47,10 @@ class const MembershipRenderer(
TextSpan( TextSpan(
text: text:
"${switch (content.status) { "${switch (content.status) {
.invite => "was invited to", .invite => previous == .knock ? "was accepted into" : "was invited to",
.join => "joined", .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", .ban => "was banned from",
.knock => "asked to join", .knock => "asked to join",
}} the room${event.sender == event.stateKey ? "" : " by"}", }} the room${event.sender == event.stateKey ? "" : " by"}",

View file

@ -2,12 +2,16 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:nexus/widgets/sheet_layout.dart"; import "package:nexus/widgets/sheet_layout.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/invites.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/get_localpart.dart";
import "package:nexus/helpers/extensions/join_room.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/main.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/expandable_image.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/widgets/linkified_text.dart";
import "package:nexus/models/room_summary.dart"; import "package:nexus/models/room_summary.dart";
@ -15,11 +19,17 @@ class const RoomSummarySheet(
final RoomSummary summary, { final RoomSummary summary, {
final IList<String>? via, final IList<String>? via,
super.key, super.key,
}) extends StatelessWidget { }) extends ConsumerWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final name = summary.name ?? summary.canonicalAlias ?? summary.roomId; final name = summary.name ?? summary.canonicalAlias ?? summary.roomId;
final isInvite = summary.membership == .invite; 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; final colorScheme = Theme.of(context).colorScheme;
return SheetLayout( return SheetLayout(
@ -83,7 +93,35 @@ class const RoomSummarySheet(
}, },
child: Text("Accept"), 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<bool>(
(_) =>
KnockSheet(summary.roomId, name: name, via: via!),
) ==
true &&
context.mounted) {
Navigator.of(context).pop();
}
},
child: Text("Knock"),
)
else if (via != null)
TextButton( TextButton(
onPressed: () { onPressed: () {
context.joinRoom( context.joinRoom(
@ -139,6 +177,17 @@ class const RoomSummarySheet(
subtitle: LinkifiedText(summary.topic!), subtitle: LinkifiedText(summary.topic!),
leading: Icon(Icons.info), 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) if (summary.encryption != null)
ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)), ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)),
if (summary.worldReadable == true) if (summary.worldReadable == true)

View file

@ -14,6 +14,7 @@ import "package:nexus/models/content/create.dart";
import "package:nexus/models/space_hierarchy.dart"; import "package:nexus/models/space_hierarchy.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/expandable_image.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/loading.dart";
import "package:nexus/widgets/room_summary_sheet.dart"; import "package:nexus/widgets/room_summary_sheet.dart";
import "package:nexus/widgets/sheet_layout.dart"; import "package:nexus/widgets/sheet_layout.dart";
@ -47,6 +48,14 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
), ),
child: const Text("Leave"), 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( : TextButton(
onPressed: () => context.joinRoom( onPressed: () => context.joinRoom(
.new(roomIdOrAlias: room.summary.roomId, via: child.via), .new(roomIdOrAlias: room.summary.roomId, via: child.via),

View file

@ -1,4 +1,5 @@
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:dynamic_color/dynamic_color.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:nexus/widgets/sheet_layout.dart"; import "package:nexus/widgets/sheet_layout.dart";
import "package:flutter_hooks/flutter_hooks.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/power_level.dart";
import "package:nexus/controllers/profile.dart"; import "package:nexus/controllers/profile.dart";
import "package:nexus/controllers/rooms.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/better_when.dart";
import "package:nexus/helpers/extensions/create_room.dart"; import "package:nexus/helpers/extensions/create_room.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/helpers/extensions/get_localpart.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/models/membership_action.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/main.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"; import "package:nexus/widgets/start_dm_sheet.dart";
final class const UserBottomSheet( final class const UserBottomSheet(
final MembershipContent member,
final String userId, { final String userId, {
final String? roomId, final String? roomId,
super.key, super.key,
@ -35,6 +37,11 @@ final class const UserBottomSheet(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
); );
final client = ref.read(ClientController.provider.notifier); 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) => void showMembershipSheet(MembershipAction action) =>
context.showAdaptiveSheet( context.showAdaptiveSheet(
@ -82,181 +89,231 @@ final class const UserBottomSheet(
), ),
); );
return SingleChildScrollView( return switch (ref.watch(
child: Padding( UserController.provider(.new(roomId: roomId, userId: userId)),
padding: .all(42), )) {
child: Column( AsyncError(:final error, :final stackTrace) => ErrorDialog(
spacing: 4, error,
mainAxisSize: .min, stackTrace,
crossAxisAlignment: .center, ),
children: [ AsyncData(value: final member) ||
Row( AsyncLoading(value: final member?) => SingleChildScrollView(
mainAxisAlignment: .end, child: Padding(
children: [ padding: .all(42),
M3EButton( child: Column(
onPressed: Navigator.of(context).pop, spacing: 4,
child: Icon(Icons.close), mainAxisSize: .min,
), crossAxisAlignment: .center,
], children: [
),
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) ...[
Row( Row(
mainAxisAlignment: .end,
children: [ children: [
Expanded( M3EButton(
child: M3EButton.icon( onPressed: Navigator.of(context).pop,
onPressed: () async { child: Icon(Icons.close),
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<bool>(
(_) => 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"),
),
), ),
], ],
), ),
SizedBox(height: 18),
if (roomId != null && ExpandableImage(
ref.watch( member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
PowerLevelController.provider( downloadFilename: member.displayName ?? userId.localpart,
.membershipAction( child: AvatarOrHash(
action: .kick, member.avatarUrl,
roomId: roomId!, member.displayName ?? userId.localpart,
targetUser: userId, 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<bool>(
(_) => 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( if (roomId != null &&
onPressed: () => showMembershipSheet(.ban), member.status == .knock &&
shape: .square, ref.watch(
icon: Icon(Icons.gavel), PowerLevelController.provider(
label: Text("Ban"), .membershipAction(
decoration: .styleFrom( action: .invite,
backgroundColor: theme.colorScheme.errorContainer, roomId: roomId!,
foregroundColor: theme.colorScheme.onErrorContainer, 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(),
};
} }
} }