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 =>
|
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"];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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,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),
|
};
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"}",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue