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 =>
(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 =>
(await _sendCommand("create_room", request.toJson()))["room_id"];

View file

@ -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),

View file

@ -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<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: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));
}

View file

@ -9,6 +9,7 @@ part "join_room.g.dart";
class const JoinRoomRequest({
required final String roomIdOrAlias,
final IList<String> via = const .empty(),
final String? reason,
/// Set when accepting an invite, so gomuks can mark DMs as such.
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: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<String> 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<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.
factory RoomSummary.fromMetadata(RoomMetadata metadata) => .new(
roomId: metadata.id,

View file

@ -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

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,12 +12,11 @@ class const MessageAvatar(
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch(
AuthorController.provider(event),
)) {
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),
context.showUserPopover(event.sender, roomId: event.roomId),
child: AvatarOrHash(
value.avatarUrl,
value.displayName ?? event.sender.localpart,

View file

@ -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(

View file

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

View file

@ -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();
}

View file

@ -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"}",

View file

@ -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<String>? 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<bool>(
(_) =>
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)

View file

@ -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),

View file

@ -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,7 +89,15 @@ final class const UserBottomSheet(
),
);
return SingleChildScrollView(
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(
@ -146,7 +161,8 @@ final class const UserBottomSheet(
Icon(
Icons.circle,
size: 4,
color: theme.colorScheme.onSurfaceVariant,
color:
theme.colorScheme.onSurfaceVariant,
),
Text(
pronoun.summary,
@ -213,6 +229,45 @@ final class const UserBottomSheet(
],
),
if (roomId != null &&
member.status == .knock &&
ref.watch(
PowerLevelController.provider(
.membershipAction(
action: .invite,
roomId: roomId!,
targetUser: userId,
),
),
))
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(
@ -228,7 +283,7 @@ final class const UserBottomSheet(
child: Row(
spacing: 8,
children: [
if (member.status == .join || member.status == .invite)
if (member.status case .join || .invite || .knock)
M3EButton.icon(
onPressed: () => showMembershipSheet(.kick),
shape: .square,
@ -257,6 +312,8 @@ final class const UserBottomSheet(
],
),
),
);
),
AsyncLoading _ => Loading(),
};
}
}