Better Member List #36

Manually merged
Henry-Hiles merged 7 commits from better-member-list into main 2026-06-05 18:39:14 -04:00
18 changed files with 539 additions and 432 deletions

View file

@ -261,12 +261,12 @@ class ClientController extends AsyncNotifier<int> {
} }
} }
Future<String?> discoverHomeserver(Uri homeserver) async { Future<Uri?> discoverHomeserver(Uri homeserver) async {
try { try {
final response = await _sendCommand("discover_homeserver", { final response = await _sendCommand("discover_homeserver", {
"user_id": "@fake-user:${homeserver.host}", "user_id": "@fake-user:${homeserver.host}",
}); });
return response["m.homeserver"]?["base_url"]; return Uri.parse(response["m.homeserver"]?["base_url"]);
} catch (error) { } catch (error) {
return null; return null;
} }

View file

@ -0,0 +1,64 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/members_by_status_controller.dart";
import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/models/configs/members_by_status_config.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/event.dart";
class MembersGroupedController extends AsyncNotifier<IMap<int?, ISet<Event>>> {
final MembersByStatusConfig config;
MembersGroupedController(this.config);
@override
Future<IMap<int?, ISet<Event>>> build() async {
final room = ref.watch(
RoomsController.provider.select((value) => value[config.roomId]),
);
final createRowId = room?.state[EventType.create.type]?[""];
final createEvent = createRowId == null ? null : room?.events[createRowId];
final createEventContent = switch (createEvent?.content) {
CreateContent content => content,
_ => null,
};
final creators = createEventContent?.additionalCreatorIds.add(
createEvent!.sender,
);
final powerLevelsRowId = room?.state[EventType.powerLevels.type]?[""];
final powerLevelsEvent = powerLevelsRowId == null
? null
: room?.events[powerLevelsRowId];
final content = switch (powerLevelsEvent?.content) {
PowerLevelsContent content => content,
_ => PowerLevelsContent(),
};
final members = await ref.watch(
MembersByStatusController.provider(config).future,
);
return members.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
final groupKey = creators?.contains(event.stateKey!) == true
? null
: content.users[event.stateKey!] ?? content.usersDefault;
return result.update(
groupKey,
(value) => value.add(event),
ifAbsent: () => .new({event}),
);
});
}
static final provider =
AsyncNotifierProvider.family<
MembersGroupedController,
IMap<int?, ISet<Event>>,
MembersByStatusConfig
>(MembersGroupedController.new);
}

View file

@ -14,17 +14,18 @@ import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/send_message_request.dart"; import "package:nexus/models/requests/send_message_request.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
class RoomChatController extends AsyncNotifier<IList<Event>> { class RoomChatController extends AsyncNotifier<IList<Event>?> {
final String roomId; final String roomId;
RoomChatController(this.roomId); RoomChatController(this.roomId);
@override @override
Future<IList<Event>> build() async { Future<IList<Event>?> build() async {
final client = ref.watch(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
final room = ref.watch( final room = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]), RoomsController.provider.select((rooms) => rooms[roomId]),
); );
if (room == null) return .new();
if (room == null) return null;
if (!room.hasFetchedState) { if (!room.hasFetchedState) {
final state = await client.getRoomState(.new(roomId: roomId)); final state = await client.getRoomState(.new(roomId: roomId));
@ -215,7 +216,7 @@ class RoomChatController extends AsyncNotifier<IList<Event>> {
} }
static final provider = AsyncNotifierProvider.family static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, IList<Event>, String>( .autoDispose<RoomChatController, IList<Event>?, String>(
RoomChatController.new, RoomChatController.new,
); );
} }

View file

@ -0,0 +1,2 @@
String? requiredValidator(String? value) =>
value == null || value.isEmpty ? "This field is required" : null;

View file

@ -11,7 +11,7 @@ import "package:nexus/controllers/shared_prefs_controller.dart";
import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/scheme_to_theme.dart"; import "package:nexus/helpers/extensions/scheme_to_theme.dart";
import "package:nexus/pages/chat_page.dart"; import "package:nexus/pages/chat_page.dart";
import "package:nexus/pages/login_page.dart"; import "package:nexus/pages/select_server_page.dart";
import "package:nexus/pages/verify_page.dart"; import "package:nexus/pages/verify_page.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
@ -124,7 +124,7 @@ class App extends StatelessWidget {
} }
if (!clientState.isLoggedIn) { if (!clientState.isLoggedIn) {
return LoginPage(); return SelectServerPage();
} else if (!clientState.isVerified) { } else if (!clientState.isVerified) {
return VerifyPage(); return VerifyPage();
} else { } else {

View file

@ -8,8 +8,6 @@ part "create.g.dart";
abstract class CreateContent extends Content with _$CreateContent { abstract class CreateContent extends Content with _$CreateContent {
CreateContent._(); CreateContent._();
factory CreateContent({ factory CreateContent({
@JsonKey(name: "creator") String? creatorId,
@JsonKey(name: "additional_creators") @JsonKey(name: "additional_creators")
@Default(IList.empty()) @Default(IList.empty())
IList<String> additionalCreatorIds, IList<String> additionalCreatorIds,

View file

@ -1,205 +1,95 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_svg/flutter_svg.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/models/homeserver.dart";
import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/divider_text.dart"; import "package:nexus/helpers/required_validator_helper.dart";
import "package:nexus/widgets/loading.dart";
class LoginPage extends HookConsumerWidget { class LoginPage extends HookConsumerWidget {
const LoginPage({super.key}); final Uri homeserver;
const LoginPage({super.key, required this.homeserver});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final client = ref.watch(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
final isLoading = useState(false); final isLoading = useState(false);
final homeserver = useState<String?>(null);
final launch = ref.watch(LaunchHelper.provider).launchUrl;
Future<void> setHomeserver(Uri? newHomeserver) async {
isLoading.value = true;
homeserver.value = newHomeserver == null
? null
: await client.discoverHomeserver(
newHomeserver.hasScheme
? newHomeserver
: Uri.https(newHomeserver.path),
);
if (homeserver.value == null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
.new(
content: Text(
"Homeserver verification failed. Is your homeserver down?",
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
backgroundColor: theme.colorScheme.errorContainer,
),
);
}
isLoading.value = false;
}
final homeserverUrl = useTextEditingController();
final username = useTextEditingController(); final username = useTextEditingController();
final password = useTextEditingController(); final password = useTextEditingController();
return Scaffold( final inputError = useState<String?>(null);
appBar: Appbar(), final formKey = useRef(GlobalKey<FormState>());
body: Center(
child: ConstrainedBox(
constraints: .new(maxWidth: 600),
child: ListView(
padding: .symmetric(horizontal: 16, vertical: 64),
children: [
Row(
children: [
SvgPicture.asset("assets/icon.svg", width: 128),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
Text("Nexus", style: theme.textTheme.displayMedium),
Text(
"A Simple Matrix Client",
style: theme.textTheme.headlineMedium,
overflow: .ellipsis,
),
],
),
),
],
),
Padding(padding: .symmetric(vertical: 12), child: Divider()),
DividerText("Enter a homeserver domain:"), Future<void> tryLogin() async {
Row(
spacing: 8,
children: [
Expanded(
child: TextField(
controller: homeserverUrl,
decoration: .new(
labelText: "Homeserver URL (e.g. matrix.org)",
),
),
),
IconButton.filled(
tooltip: "Confirm homeserver choice",
onPressed: isLoading.value
? null
: () => setHomeserver(.tryParse(homeserverUrl.text)),
icon: Icon(Icons.check),
),
],
),
DividerText("Or, choose from some popular homeservers:"),
...(<Homeserver>[
.new(
name: "Matrix.org",
description:
"The Matrix.org Foundation offers the matrix.org homeserver as an easy entry point for anyone wanting to try out Matrix.",
url: .https("matrix.org"),
iconUrl:
"https://raw.githubusercontent.com/element-hq/logos/refs/heads/master/matrix/matrix-favicon${Theme.brightnessOf(context) == Brightness.dark ? "-white" : ""}.png",
),
.new(
name: "Federated Nexus",
description:
"Federated Nexus is a community resource hosting multiple FOSS (especially federated) services, including Matrix and Forgejo. By the same developers who made Nexus client.",
url: .https("federated.nexus"),
iconUrl: "https://federated.nexus/images/icon.png",
),
.new(
name: "Unredacted",
description:
"Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.",
url: .https("unredacted.org", "services/si/matrix"),
iconUrl: "https://unredacted.org/favicon.ico",
),
].map(
(homeserver) => Card(
child: ListTile(
title: Text(homeserver.name),
leading: Image.network(
homeserver.iconUrl,
errorBuilder: (_, _, _) => SizedBox.shrink(),
height: 32,
),
subtitle: Text(homeserver.description),
onTap: isLoading.value
? null
: () => setHomeserver(homeserver.url),
trailing: IconButton(
tooltip: "Launch homeserver info page",
onPressed: () => launch(homeserver.url),
icon: Icon(Icons.info_outline),
),
),
),
)),
SizedBox(height: 8),
TextButton(
onPressed: () => launch(.https("servers.joinmatrix.org")),
child: Text("See more homeservers..."),
),
if (isLoading.value)
Padding(padding: .only(top: 32), child: Loading())
else if (homeserver.value != null) ...[
DividerText("Then, sign in:"),
SizedBox(height: 4),
TextField(
decoration: .new(label: Text("Username")),
controller: username,
),
SizedBox(height: 12),
TextField(
decoration: .new(label: Text("Password")),
controller: password,
obscureText: true,
),
SizedBox(height: 12),
ElevatedButton(
onPressed: () async {
isLoading.value = true; isLoading.value = true;
try {
if (formKey.value.currentState?.validate() != true) return;
final error = await client.login( final error = await client.login(
.new( .new(
username: username.text, username: username.text,
password: password.text, password: password.text,
homeserverUrl: homeserver.value!, homeserverUrl: homeserver.origin,
), ),
); );
if (error != null && context.mounted) { if (error != null) {
ScaffoldMessenger.of(context).showSnackBar( inputError.value = error;
.new( isLoading.value = false;
content: Text( } else {
"Login failed. Is your password right?\nError: $error", if (context.mounted) Navigator.of(context).pop();
style: .new( }
color: theme.colorScheme.onErrorContainer, } finally {
),
),
backgroundColor: theme.colorScheme.errorContainer,
),
);
isLoading.value = false; isLoading.value = false;
} }
}, }
return Scaffold(
appBar: Appbar(
leading: IconButton(
icon: Icon(Icons.arrow_back),
onPressed: Navigator.of(context).pop,
),
),
body: AlertDialog(
title: Text("Login to ${homeserver.host}"),
content: Form(
key: formKey.value,
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
TextFormField(
autofocus: true,
textInputAction: .next,
autovalidateMode: .onUserInteraction,
validator: requiredValidator,
decoration: .new(label: Text("Username")),
controller: username,
),
SizedBox(height: 12),
TextFormField(
textInputAction: .done,
decoration: .new(
label: Text("Password"),
errorText: inputError.value,
errorMaxLines: 5,
),
autovalidateMode: .onUserInteraction,
validator: requiredValidator,
controller: password,
obscureText: true,
),
],
),
),
actions: [
TextButton(
onPressed: isLoading.value ? null : tryLogin,
child: Text("Sign In"), child: Text("Sign In"),
), ),
], ],
],
),
),
), ),
); );
} }

View file

@ -0,0 +1,169 @@
import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_svg/flutter_svg.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/models/homeserver.dart";
import "package:nexus/pages/login_page.dart";
import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/divider_text.dart";
class SelectServerPage extends HookConsumerWidget {
const SelectServerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final launch = ref.watch(LaunchHelper.provider).launchUrl;
final isLoading = useState(false);
final homeserverUrl = useTextEditingController();
Future<void> setHomeserver(Uri? newHomeserver) async {
isLoading.value = true;
try {
if (newHomeserver?.hasScheme == false) {
newHomeserver = Uri.https(newHomeserver!.path);
}
final newUrl = newHomeserver == null
? null
: await ref
.watch(ClientController.provider.notifier)
.discoverHomeserver(newHomeserver);
if (context.mounted) {
if (newUrl == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Homeserver verification failed. Is your homeserver down?",
style: .new(color: theme.colorScheme.onErrorContainer),
),
backgroundColor: theme.colorScheme.errorContainer,
),
);
} else {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => LoginPage(homeserver: newUrl)),
);
}
}
} finally {
isLoading.value = false;
}
}
return Scaffold(
appBar: Appbar(),
body: Center(
child: ConstrainedBox(
constraints: .new(maxWidth: 600),
child: ListView(
children: [
Row(
children: [
SvgPicture.asset("assets/icon.svg", width: 128),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
Text("Nexus", style: theme.textTheme.displayMedium),
Text(
"A Simple Matrix Client",
style: theme.textTheme.headlineMedium,
overflow: .ellipsis,
),
],
),
),
],
),
Padding(padding: .symmetric(vertical: 12), child: Divider()),
DividerText("Enter a homeserver domain:"),
Row(
spacing: 8,
children: [
Expanded(
child: TextField(
textInputAction: .done,
autofocus: true,
onSubmitted: (text) => setHomeserver(.tryParse(text)),
controller: homeserverUrl,
decoration: .new(
labelText: "Homeserver URL",
hintText: "matrix.org",
),
),
),
IconButton.filled(
tooltip: "Confirm homeserver choice",
onPressed: isLoading.value
? null
: () => setHomeserver(.tryParse(homeserverUrl.text)),
icon: Icon(Icons.check),
),
],
),
DividerText("Or, choose from some popular homeservers:"),
...(<Homeserver>[
.new(
name: "Matrix.org",
description:
"The Matrix.org Foundation offers the matrix.org homeserver as an easy entry point for anyone wanting to try out Matrix.",
url: .https("matrix.org"),
iconUrl:
"https://raw.githubusercontent.com/element-hq/logos/refs/heads/master/matrix/matrix-favicon${Theme.brightnessOf(context) == Brightness.dark ? "-white" : ""}.png",
),
.new(
name: "Federated Nexus",
description:
"Federated Nexus is a community resource hosting multiple FOSS (especially federated) services, including Matrix and Forgejo. By the same developers who made Nexus client.",
url: .https("federated.nexus"),
iconUrl: "https://federated.nexus/images/icon.png",
),
.new(
name: "Unredacted",
description:
"Unredacted is a 501(c)(3) non-profit organization that builds Internet infrastructure and services to help people evade censorship and protect their right to privacy.",
url: .https("unredacted.org", "services/si/matrix"),
iconUrl: "https://unredacted.org/favicon.ico",
),
].map(
(homeserver) => Card(
child: ListTile(
enabled: !isLoading.value,
title: Text(homeserver.name),
leading: Image.network(
homeserver.iconUrl,
errorBuilder: (_, _, _) => SizedBox.shrink(),
height: 32,
),
subtitle: Text(homeserver.description),
onTap: isLoading.value
? null
: () => setHomeserver(homeserver.url),
trailing: IconButton(
tooltip: "Launch homeserver info page",
onPressed: () => launch(homeserver.url),
icon: Icon(Icons.info_outline),
),
),
),
)),
TextButton(
onPressed: () => launch(.https("servers.joinmatrix.org")),
child: Text("See more homeservers..."),
),
],
),
),
),
);
}
}

View file

@ -3,7 +3,7 @@ import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/helpers/required_validator_helper.dart";
class VerifyPage extends HookConsumerWidget { class VerifyPage extends HookConsumerWidget {
const VerifyPage({super.key}); const VerifyPage({super.key});
@ -11,12 +11,17 @@ class VerifyPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final passphraseController = useTextEditingController(); final passphraseController = useTextEditingController();
final isVerifying = useState(false); final isLoading = useState(false);
final inputError = useState<String?>(null);
final formKey = useRef(GlobalKey<FormState>());
return Scaffold( return Scaffold(
appBar: Appbar(), appBar: Appbar(),
body: AlertDialog( body: AlertDialog(
title: Text("Verify"), title: Text("Verify"),
content: Column( content: Form(
key: formKey.value,
child: Column(
mainAxisSize: .min, mainAxisSize: .min,
crossAxisAlignment: .start, crossAxisAlignment: .start,
children: [ children: [
@ -24,57 +29,38 @@ class VerifyPage extends HookConsumerWidget {
"Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.", "Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.",
), ),
SizedBox(height: 12), SizedBox(height: 12),
FormTextInput( TextFormField(
required: false,
autofocus: true, autofocus: true,
capitalize: true,
controller: passphraseController, controller: passphraseController,
obscure: true, textInputAction: .done,
title: "Recovery Key or Passphrase", autovalidateMode: .onUserInteraction,
validator: requiredValidator,
obscureText: true,
decoration: .new(
label: Text("Recovery Key or Passphrase"),
errorText: inputError.value,
),
), ),
], ],
), ),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: isVerifying.value onPressed: isLoading.value
? null ? null
: () async { : () async {
final scaffoldMessenger = ScaffoldMessenger.of(context); isLoading.value = true;
final snackbar = scaffoldMessenger.showSnackBar(
.new(
content: Text(
"Attempting to verify with recovery key...",
),
duration: .new(days: 999),
),
);
isVerifying.value = true; try {
if (formKey.value.currentState?.validate() != true) {
return;
}
final error = await ref inputError.value = await ref
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)
.verify(passphraseController.text); .verify(passphraseController.text);
} finally {
snackbar.close(); isLoading.value = false;
if (error != null) {
isVerifying.value = false;
if (context.mounted) {
scaffoldMessenger.showSnackBar(
.new(
backgroundColor: Theme.of(
context,
).colorScheme.errorContainer,
content: Text(
"Verification failed. Is your passphrase correct?\nError: $error",
style: .new(
color: Theme.of(
context,
).colorScheme.onErrorContainer,
),
),
),
);
}
} }
}, },
child: Text("Verify"), child: Text("Verify"),

View file

@ -1,81 +0,0 @@
import "package:flutter/material.dart";
import "package:flutter/services.dart";
class FormTextInput extends StatelessWidget {
final List<String? Function(String value)> extraValidators;
final TextEditingController? controller;
final TextInputType keyboardType;
final String? initialValue;
final bool readOnly;
final bool obscure;
final String? title;
final int? minLines;
final int? maxLength;
final bool outlined;
final int? maxLines;
final bool capitalize;
final bool required;
final bool autocorrect;
final void Function()? onTap;
final Widget? trailing;
final InputBorder? border;
final List<TextInputFormatter>? formatters;
final bool autofocus;
const FormTextInput({
super.key,
this.border,
this.controller,
this.autofocus = false,
this.title,
this.obscure = false,
this.readOnly = false,
this.extraValidators = const [],
this.keyboardType = TextInputType.text,
this.initialValue,
this.minLines,
this.capitalize = false,
this.maxLength,
this.formatters,
this.maxLines = 1,
this.outlined = true,
this.trailing,
this.onTap,
this.autocorrect = true,
this.required = true,
});
@override
Widget build(BuildContext context) => TextFormField(
autofocus: autofocus,
controller: controller,
keyboardType: keyboardType,
readOnly: readOnly,
minLines: minLines,
maxLines: maxLines,
maxLength: maxLength,
inputFormatters: formatters,
textCapitalization: capitalize ? .sentences : .none,
initialValue: initialValue,
autocorrect: autocorrect,
obscureText: obscure,
onTap: onTap,
decoration: .new(
labelText: title,
border: border ?? (outlined ? null : const UnderlineInputBorder()),
suffixIcon: trailing,
),
validator: (value) {
if ((value?.isEmpty ?? true) && required) {
return "This field is required";
}
for (final validator in extraValidators) {
final reason = validator(value!);
if (reason != null) return reason;
}
return null;
},
);
}

View file

@ -6,7 +6,6 @@ import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/key_controller.dart"; import "package:nexus/controllers/key_controller.dart";
import "package:nexus/controllers/spaces_controller.dart"; import "package:nexus/controllers/spaces_controller.dart";
import "package:nexus/helpers/extensions/link_to_mention.dart"; import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/widgets/form_text_input.dart";
class JoinDialog extends HookWidget { class JoinDialog extends HookWidget {
final WidgetRef ref; final WidgetRef ref;
@ -23,11 +22,9 @@ class JoinDialog extends HookWidget {
children: [ children: [
Text("Enter the room alias, Matrix URI, or Matrix.to link."), Text("Enter the room alias, Matrix URI, or Matrix.to link."),
SizedBox(height: 12), SizedBox(height: 12),
FormTextInput( TextField(
required: false,
capitalize: true,
controller: roomAlias, controller: roomAlias,
title: "#room:server", decoration: .new(hintText: "#room:server"),
), ),
], ],
), ),

View file

@ -1,13 +1,16 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/members_by_status_controller.dart"; import "package:m3e_buttons/m3e_buttons.dart";
import "package:material_segmented_list/material_segmented_list.dart";
import "package:nexus/controllers/members_grouped_controller.dart";
import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/helpers/extensions/string_to_color.dart"; import "package:nexus/helpers/extensions/string_to_color.dart";
import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/membership.dart";
import "package:nexus/models/membership_status.dart"; import "package:nexus/models/membership_status.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
@ -18,12 +21,19 @@ class MemberList extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final status = useState(MembershipStatus.join); final status = useState(MembershipStatus.join);
final membersProvider = ref.watch(
MembersByStatusController.provider( final membersData = ref.watch(
MembersGroupedController.provider(
.new(roomId: roomId, status: status.value), .new(roomId: roomId, status: status.value),
), ),
); );
final options = {
"Joined": MembershipStatus.join,
"Invited": MembershipStatus.invite,
"Banned": MembershipStatus.ban,
};
return Drawer( return Drawer(
shape: Border(), shape: Border(),
child: Column( child: Column(
@ -42,56 +52,79 @@ class MemberList extends HookConsumerWidget {
), ),
], ],
), ),
Wrap( M3EToggleButtonGroup(
alignment: .center, type: M3EButtonGroupType.connected,
spacing: 8, selectedIndex: options.values.toIList().indexOf(status.value),
runSpacing: 8, onSelectedIndexChanged: (index) =>
children: [ status.value = options.values.elementAt(index ?? 0),
FilterChip( // overflow: M3EButtonGroupOverflow.menu,
label: Text("Joined"), actions: options
onSelected: (value) => status.value = .join, .mapTo(
selected: status.value == .join, (name, value) => M3EToggleButtonGroupAction(
checkedLabel: Text(
"$name${switch (membersData) {
AsyncData(:final value) || AsyncLoading(:final value?) => " (${value.values.expand((element) => element).length})",
_ => "",
}}",
), ),
FilterChip( label: Text(name),
label: Text("Invited"),
onSelected: (value) => status.value = .invite,
selected: status.value == .invite,
), ),
FilterChip( )
label: Text("Banned"), .toList(),
onSelected: (value) => status.value = .ban,
selected: status.value == .ban,
), ),
],
), switch (membersData) {
switch (membersProvider) {
AsyncError(:final error, :final stackTrace) => ErrorDialog( AsyncError(:final error, :final stackTrace) => ErrorDialog(
error, error,
stackTrace, stackTrace,
), ),
AsyncData(:final value) || AsyncLoading(:final value?) => Expanded( AsyncData(:final value) || AsyncLoading(:final value?) =>
value.isEmpty
? Center(
child: Padding(
padding: .symmetric(vertical: 18),
child: Text(
"No ${options.keys.toIList()[options.values.toIList().indexOf(status.value)]} Members",
style: Theme.of(context).textTheme.headlineSmall,
),
),
)
: Expanded(
child: ListView( child: ListView(
children: value padding: .all(12),
children: [
for (final MapEntry(key: powerLevel, value: members)
in value.toEntryIList(
compare: (a, b) => (b?.key ?? double.infinity)
.compareTo(a?.key ?? double.infinity),
)) ...[
Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: DividerText(
powerLevel == null
? "Creators"
: "Power Level $powerLevel",
),
),
SegmentedListSection(
children: members
.map( .map(
(member) => switch (member.content) { (member) => switch (member.content) {
MembershipContent( MembershipContent(
:final avatarUrl, :final avatarUrl,
:final displayName, :final displayName,
) => ) =>
InkWell( SegmentedListTile(
onTapUp: (details) => context.showUserPopover( onTap: () {},
member.content as MembershipContent, // context.showUserPopover(
member.stateKey!, // member.content as MembershipContent,
roomId: roomId, // member.stateKey!,
globalPosition: details.globalPosition, // roomId: roomId,
), // globalPosition: details.globalPosition,
child: ListTile( // ),
leading: AvatarOrHash(
avatarUrl,
displayName ?? member.sender.localpart,
),
title: Text( title: Text(
displayName ?? member.stateKey!.localpart, displayName ??
member.stateKey!.localpart,
overflow: .ellipsis, overflow: .ellipsis,
style: .new( style: .new(
color: member.stateKey!.colorHash, color: member.stateKey!.colorHash,
@ -102,13 +135,23 @@ class MemberList extends HookConsumerWidget {
member.stateKey!, member.stateKey!,
overflow: .ellipsis, overflow: .ellipsis,
), ),
leading: AvatarOrHash(
avatarUrl,
displayName ??
member.sender.localpart,
), ),
), ),
_ => SizedBox.shrink(), _ => throw Exception(
"Member content was not MembershipContent",
),
}, },
) )
.toList(), .toList(),
), ),
SizedBox(height: 4),
],
],
),
), ),
AsyncLoading _ => Loading(), AsyncLoading _ => Loading(),
}, },

View file

@ -1,4 +1,3 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/rooms_controller.dart";
@ -93,8 +92,10 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
), ),
), ),
), ),
leading: isDesktop && room != null leading: isDesktop
? AvatarOrHash( ? room == null
? null
: AvatarOrHash(
room.metadata?.avatar, room.metadata?.avatar,
room.metadata?.name ?? "Unnamed Room", room.metadata?.name ?? "Unnamed Room",
height: 24, height: 24,
@ -123,7 +124,9 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
), ),
], ],
), ),
actions: [ actions: room == null
? .new()
: .new([
IconButton( IconButton(
onPressed: null, onPressed: null,
icon: Icon(Icons.push_pin), icon: Icon(Icons.push_pin),
@ -134,8 +137,8 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
tooltip: "Open member list", tooltip: "Open member list",
icon: Icon(Icons.people), icon: Icon(Icons.people),
), ),
if (room != null) RoomMenu(room), RoomMenu(room),
].toIList(), ]),
); );
} }
} }

View file

@ -22,7 +22,6 @@ import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart"; import "package:nexus/widgets/room_appbar.dart";
import "package:nexus/widgets/flash_wrapper.dart"; import "package:nexus/widgets/flash_wrapper.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/form_text_input.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
import "package:super_sliver_list/super_sliver_list.dart"; import "package:super_sliver_list/super_sliver_list.dart";
@ -51,6 +50,12 @@ class RoomChat extends HookConsumerWidget {
final userId = ref.watch(ClientStateController.provider)?.userId; final userId = ref.watch(ClientStateController.provider)?.userId;
final theme = Theme.of(context); final theme = Theme.of(context);
final nothing = Center(
child: Text(
"Nothing to see here...",
style: theme.textTheme.headlineMedium,
),
);
if (userId == null || this.roomId == null) { if (userId == null || this.roomId == null) {
return Scaffold( return Scaffold(
appBar: RoomAppbar( appBar: RoomAppbar(
@ -59,12 +64,7 @@ class RoomChat extends HookConsumerWidget {
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
onOpenMemberList: null, onOpenMemberList: null,
), ),
body: Center( body: nothing,
child: Text(
"Nothing to see here...",
style: theme.textTheme.headlineMedium,
),
),
); );
} }
@ -82,7 +82,7 @@ class RoomChat extends HookConsumerWidget {
final topEventBeforeLoad = useState<String?>(null); final topEventBeforeLoad = useState<String?>(null);
Future<void> loadOlder() async { Future<void> loadOlder() async {
if (controllerData case AsyncData(:final value)) { if (controllerData case AsyncData(:final value?)) {
topEventBeforeLoad.value = value.firstOrNull?.eventId; topEventBeforeLoad.value = value.firstOrNull?.eventId;
await notifier.loadOlder(); await notifier.loadOlder();
} }
@ -106,7 +106,7 @@ class RoomChat extends HookConsumerWidget {
useEffect(() { useEffect(() {
if (controllerData case AsyncData( if (controllerData case AsyncData(
:final value, :final value?,
) when scrollController.hasClients) { ) when scrollController.hasClients) {
if (topEventBeforeLoad.value != null) { if (topEventBeforeLoad.value != null) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@ -288,11 +288,12 @@ class RoomChat extends HookConsumerWidget {
"Are you sure you want to delete this message? This can not be reversed.", "Are you sure you want to delete this message? This can not be reversed.",
), ),
SizedBox(height: 12), SizedBox(height: 12),
FormTextInput( TextField(
required: false,
capitalize: true,
controller: deleteReasonController, controller: deleteReasonController,
title: "Reason for deletion (optional)", textCapitalization: .sentences,
decoration: .new(
labelText: "Reason for deletion (optional)",
),
), ),
], ],
), ),
@ -340,11 +341,12 @@ class RoomChat extends HookConsumerWidget {
), ),
SizedBox(height: 12), SizedBox(height: 12),
FormTextInput( TextField(
required: false,
capitalize: true,
controller: reasonController, controller: reasonController,
title: "Reason for report (optional)", textCapitalization: .sentences,
decoration: .new(
labelText: "Reason for report (optional)",
),
), ),
], ],
), ),
@ -400,7 +402,7 @@ class RoomChat extends HookConsumerWidget {
child: Padding( child: Padding(
padding: .symmetric(horizontal: 12), padding: .symmetric(horizontal: 12),
child: switch (controllerData) { child: switch (controllerData) {
AsyncData(:final value) || AsyncData(:final value?) ||
AsyncLoading(:final value?) => CustomScrollView( AsyncLoading(:final value?) => CustomScrollView(
controller: scrollController, controller: scrollController,
slivers: [ slivers: [
@ -466,6 +468,7 @@ class RoomChat extends HookConsumerWidget {
), ),
], ],
), ),
AsyncData() => nothing,
AsyncLoading() => Loading(), AsyncLoading() => Loading(),
AsyncError(:final error, :final stackTrace) => AsyncError(:final error, :final stackTrace) =>
ErrorDialog(error, stackTrace), ErrorDialog(error, stackTrace),

View file

@ -39,9 +39,7 @@ class Sidebar extends HookConsumerWidget {
(room) => room.metadata?.id == selectedRoomId, (room) => room.metadata?.id == selectedRoomId,
); );
final selectedRoomIndex = indexOfSelectedRoom == -1 final selectedRoomIndex = indexOfSelectedRoom == -1
? selectedSpace.children.isEmpty
? null ? null
: 0
: indexOfSelectedRoom; : indexOfSelectedRoom;
return Drawer( return Drawer(

View file

@ -14,7 +14,6 @@ import "package:nexus/models/requests/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";
import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/form_text_input.dart";
class UserPopover extends ConsumerWidget { class UserPopover extends ConsumerWidget {
final MembershipContent member; final MembershipContent member;
@ -41,11 +40,12 @@ class UserPopover extends ConsumerWidget {
children: [ children: [
Text("Are you sure you want to ${action.name} $userId?"), Text("Are you sure you want to ${action.name} $userId?"),
SizedBox(height: 12), SizedBox(height: 12),
FormTextInput( TextField(
required: false, textCapitalization: .sentences,
capitalize: true,
controller: actionReasonController, controller: actionReasonController,
title: "Reason for ${action.name} (optional)", decoration: .new(
labelText: "Reason for ${action.name} (optional)",
),
), ),
], ],
), ),

View file

@ -299,6 +299,14 @@ packages:
url: "https://github.com/Henry-Hiles/emoji_text_field" url: "https://github.com/Henry-Hiles/emoji_text_field"
source: git source: git
version: "1.0.0" version: "1.0.0"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -744,6 +752,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
m3e_buttons:
dependency: "direct main"
description:
name: m3e_buttons
sha256: "50cdf9ba30fb3ab529afafb0e837484549f8599f1f109ac07da50951febaace1"
url: "https://pub.dev"
source: hosted
version: "0.0.3"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -760,6 +776,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.13.0"
material_segmented_list:
dependency: "direct main"
description:
name: material_segmented_list
sha256: "384bfd41a78e745397ceff1dd39700961e6a5419ad911d1797bcc13ea3824241"
url: "https://pub.dev"
source: hosted
version: "1.0.5"
measure_size: measure_size:
dependency: "direct main" dependency: "direct main"
description: description:
@ -848,6 +872,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
motor:
dependency: transitive
description:
name: motor
sha256: cbd49f21b00e568c2b1a55f134ed803614a107782f4fea7769693bca32940c58
url: "https://pub.dev"
source: hosted
version: "1.1.0"
native_toolchain_c: native_toolchain_c:
dependency: transitive dependency: transitive
description: description:

View file

@ -64,6 +64,8 @@ dependencies:
media_kit_video: 2.0.1 media_kit_video: 2.0.1
media_kit_libs_video: 1.0.7 media_kit_libs_video: 1.0.7
measure_size: ^5.0.2 measure_size: ^5.0.2
material_segmented_list: ^1.0.5
m3e_buttons: ^0.0.3
dev_dependencies: dev_dependencies:
build_runner: 2.15.0 build_runner: 2.15.0