parent
a1a15af30d
commit
427e06be3e
16 changed files with 399 additions and 194 deletions
|
|
@ -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"];
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
51
lib/widgets/knock_sheet.dart
Normal file
51
lib/widgets/knock_sheet.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue