1
0
Fork 0
forked from Nexus/nexus

Compare commits

..

15 commits

29 changed files with 824 additions and 591 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

@ -24,7 +24,7 @@ class MembersController extends AsyncNotifier<ISet<Event>> {
.getRoomState( .getRoomState(
GetRoomStateRequest( GetRoomStateRequest(
roomId: roomId, roomId: roomId,
fetchMembers: room.metadata?.hasMemberList ?? true, fetchMembers: !(room.metadata?.hasMemberList ?? false),
includeMembers: true, includeMembers: true,
), ),
); );

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

@ -88,6 +88,7 @@ class SpacesController extends Notifier<IList<Space>> {
final children = childRoomsBySpaceId[id] ?? .new(); final children = childRoomsBySpaceId[id] ?? .new();
return Space( return Space(
subSpaces: const IList.empty(), // TODO
id: id, id: id,
title: room.metadata?.name ?? "Unnamed Room", title: room.metadata?.name ?? "Unnamed Room",
room: room, room: room,
@ -103,12 +104,14 @@ class SpacesController extends Notifier<IList<Space>> {
title: "Home", title: "Home",
icon: Icons.home, icon: Icons.home,
children: homeRooms, children: homeRooms,
subSpaces: .new(),
), ),
.new( .new(
id: "dms", id: "dms",
title: "Direct Messages", title: "Direct Messages",
icon: Icons.people, icon: Icons.people,
children: dmRooms, children: dmRooms,
subSpaces: .new(),
), ),
...topLevelSpacesList, ...topLevelSpacesList,
] ]

View file

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

View file

@ -1,4 +1,5 @@
import "dart:io"; import "dart:io";
import "package:dynamic_color/dynamic_color.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart"; import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
@ -11,13 +12,12 @@ 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";
import "package:window_manager/window_manager.dart"; import "package:window_manager/window_manager.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:dynamic_system_colors/dynamic_system_colors.dart";
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@ -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

@ -2,6 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/widgets.dart"; import "package:flutter/widgets.dart";
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/subspace.dart";
part "space.freezed.dart"; part "space.freezed.dart";
@freezed @freezed
@ -12,5 +13,6 @@ abstract class Space with _$Space {
IconData? icon, IconData? icon,
Room? room, Room? room,
required IList<Room> children, required IList<Room> children,
required IList<Subspace> subSpaces,
}) = _Space; }) = _Space;
} }

10
lib/models/subspace.dart Normal file
View file

@ -0,0 +1,10 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/room.dart";
part "subspace.freezed.dart";
@freezed
abstract class Subspace with _$Subspace {
const factory Subspace({required Room room, required IList<Room> children}) =
_Subspace;
}

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();
final inputError = useState<String?>(null);
final formKey = useRef(GlobalKey<FormState>());
Future<void> tryLogin() async {
isLoading.value = true;
try {
if (formKey.value.currentState?.validate() != true) return;
final error = await client.login(
.new(
username: username.text,
password: password.text,
homeserverUrl: homeserver.origin,
),
);
if (error != null) {
inputError.value = error;
isLoading.value = false;
} else {
if (context.mounted) Navigator.of(context).pop();
}
} finally {
isLoading.value = false;
}
}
return Scaffold( return Scaffold(
appBar: Appbar(), appBar: Appbar(
body: Center( leading: IconButton(
child: ConstrainedBox( icon: Icon(Icons.arrow_back),
constraints: .new(maxWidth: 600), onPressed: Navigator.of(context).pop,
child: ListView( ),
padding: .symmetric(horizontal: 16, vertical: 64), ),
body: AlertDialog(
title: Text("Login to ${homeserver.host}"),
content: Form(
key: formKey.value,
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [ children: [
Row( TextFormField(
children: [ autofocus: true,
SvgPicture.asset("assets/icon.svg", width: 128), textInputAction: .next,
SizedBox(width: 12), autovalidateMode: .onUserInteraction,
Expanded( validator: requiredValidator,
child: Column( decoration: .new(label: Text("Username")),
crossAxisAlignment: .start, controller: username,
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()), SizedBox(height: 12),
TextFormField(
DividerText("Enter a homeserver domain:"), textInputAction: .done,
Row( decoration: .new(
spacing: 8, label: Text("Password"),
children: [ errorText: inputError.value,
Expanded( errorMaxLines: 5,
child: TextField( ),
controller: homeserverUrl, autovalidateMode: .onUserInteraction,
decoration: .new( validator: requiredValidator,
labelText: "Homeserver URL (e.g. matrix.org)", controller: password,
), obscureText: true,
),
),
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;
final error = await client.login(
.new(
username: username.text,
password: password.text,
homeserverUrl: homeserver.value!,
),
);
if (error != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
.new(
content: Text(
"Login failed. Is your password right?\nError: $error",
style: .new(
color: theme.colorScheme.onErrorContainer,
),
),
backgroundColor: theme.colorScheme.errorContainer,
),
);
isLoading.value = false;
}
},
child: Text("Sign In"),
),
],
], ],
), ),
), ),
actions: [
TextButton(
onPressed: isLoading.value ? null : tryLogin,
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,70 +11,56 @@ 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(
mainAxisSize: .min, key: formKey.value,
crossAxisAlignment: .start, child: Column(
children: [ mainAxisSize: .min,
Text( crossAxisAlignment: .start,
"Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.", children: [
), Text(
SizedBox(height: 12), "Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.",
FormTextInput( ),
required: false, SizedBox(height: 12),
autofocus: true, TextFormField(
capitalize: true, autofocus: 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) {
final error = await ref return;
.watch(ClientController.provider.notifier)
.verify(passphraseController.text);
snackbar.close();
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,
),
),
),
);
} }
inputError.value = await ref
.watch(ClientController.provider.notifier)
.verify(passphraseController.text);
} finally {
isLoading.value = false;
} }
}, },
child: Text("Verify"), child: Text("Verify"),

View file

@ -11,15 +11,11 @@ class AvatarOrHash extends ConsumerWidget {
final Uri? avatar; final Uri? avatar;
final String title; final String title;
final Widget? fallback; final Widget? fallback;
final bool hasBadge;
final int badgeNumber;
final double height; final double height;
const AvatarOrHash( const AvatarOrHash(
this.avatar, this.avatar,
this.title, { this.title, {
this.fallback, this.fallback,
this.badgeNumber = 0,
this.hasBadge = false,
this.height = 24, this.height = 24,
super.key, super.key,
}); });
@ -44,30 +40,24 @@ class AvatarOrHash extends ConsumerWidget {
width: height, width: height,
height: height, height: height,
child: Center( child: Center(
child: Badge( child: ClipRRect(
isLabelVisible: hasBadge, borderRadius: .all(.circular((height - 8) / 2.5)),
label: badgeNumber != 0 ? Text(badgeNumber.toString()) : null, child: SizedBox(
smallSize: 12, width: height,
backgroundColor: Theme.of(context).colorScheme.primary, height: height,
child: ClipRRect( child: parsedAvatar == null
borderRadius: .all(.circular((height - 8) / 2.5)), ? fallback ?? box
child: SizedBox( : Image(
width: height, image: CachedNetworkImage(
height: height, parsedAvatar.toString(),
child: parsedAvatar == null ref.watch(CrossCacheController.provider),
? fallback ?? box headers: ref.headers,
: Image(
image: CachedNetworkImage(
parsedAvatar.toString(),
ref.watch(CrossCacheController.provider),
headers: ref.headers,
),
fit: .cover,
loadingBuilder: (_, child, loadingProgress) =>
loadingProgress == null ? child : fallback ?? box,
errorBuilder: (_, _, _) => fallback ?? box,
), ),
), fit: .cover,
loadingBuilder: (_, child, loadingProgress) =>
loadingProgress == null ? child : fallback ?? box,
errorBuilder: (_, _, _) => fallback ?? box,
),
), ),
), ),
), ),

View file

@ -1,5 +1,6 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:fluttertagger/fluttertagger.dart"; import "package:fluttertagger/fluttertagger.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
@ -135,21 +136,34 @@ class Composer extends HookConsumerWidget {
query.value = newQuery; query.value = newQuery;
}, },
triggerCharacterAndStyles: {"@": style, "#": style}, triggerCharacterAndStyles: {"@": style, "#": style},
builder: (context, key) => TextField( builder: (context, key) => Focus(
maxLines: 12, onKeyEvent: (_, event) {
minLines: 1, if (event is KeyDownEvent &&
autofocus: true, event.logicalKey ==
decoration: .new( LogicalKeyboardKey.enter) {
hintText: "Your message here...", final shiftPressed =
border: .none, HardwareKeyboard.instance.isShiftPressed;
if (!shiftPressed) {
send();
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child: TextField(
maxLines: 12,
minLines: 1,
autofocus: true,
decoration: .new(
hintText: "Your message here...",
border: .none,
),
controller: controller.value,
key: key,
focusNode: node,
), ),
controller: controller.value,
key: key,
onSubmitted: (_) => send(),
// Don't defocus on submit
onEditingComplete: () {},
textInputAction: .done,
focusNode: node,
), ),
), ),
), ),

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"),
), ),
], ],
), ),
@ -87,9 +84,15 @@ class JoinDialog extends HookWidget {
space?.id ?? space?.id ??
spaces spaces
.firstWhere( .firstWhere(
(space) => space.children.any( (space) =>
(child) => child.metadata?.id == id, space.children.any(
), (child) =>
child.metadata?.id == id,
) ||
space.subSpaces.any(
(child) =>
child.room.metadata?.id == id,
),
) )
.id, .id,
); );

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 = <String, MembershipStatus>{
"Joined": .join,
"Invited": .invite,
"Banned": .ban,
};
return Drawer( return Drawer(
shape: Border(), shape: Border(),
child: Column( child: Column(
@ -42,74 +52,107 @@ class MemberList extends HookConsumerWidget {
), ),
], ],
), ),
Wrap( M3EToggleButtonGroup(
alignment: .center, type: .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(
FilterChip( "$name${switch (membersData) {
label: Text("Invited"), AsyncData(:final value) || AsyncLoading(:final value?) => " (${value.values.expand((element) => element).length})",
onSelected: (value) => status.value = .invite, _ => "",
selected: status.value == .invite, }}",
), ),
FilterChip( label: Text(name),
label: Text("Banned"), ),
onSelected: (value) => status.value = .ban, )
selected: status.value == .ban, .toList(),
),
],
), ),
switch (membersProvider) {
switch (membersData) {
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?) =>
child: ListView( value.isEmpty
children: value ? Center(
.map( child: Padding(
(member) => switch (member.content) { padding: .symmetric(vertical: 18),
MembershipContent( child: Text(
:final avatarUrl, "No ${options.keys.toIList()[options.values.toIList().indexOf(status.value)]} Members",
:final displayName, style: Theme.of(context).textTheme.headlineSmall,
) => ),
InkWell( ),
onTapUp: (details) => context.showUserPopover(
member.content as MembershipContent,
member.stateKey!,
roomId: roomId,
globalPosition: details.globalPosition,
),
child: ListTile(
leading: AvatarOrHash(
avatarUrl,
displayName ?? member.sender.localpart,
),
title: Text(
displayName ?? member.stateKey!.localpart,
overflow: .ellipsis,
style: .new(
color: member.stateKey!.colorHash,
fontWeight: .bold,
),
),
subtitle: Text(
member.stateKey!,
overflow: .ellipsis,
),
),
),
_ => SizedBox.shrink(),
},
) )
.toList(), : Expanded(
), child: ListView(
), 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: .symmetric(horizontal: 4),
child: DividerText(
powerLevel == null
? "Creators"
: "Power Level $powerLevel",
),
),
SegmentedListSection(
children: members
.map(
(member) => switch (member.content) {
MembershipContent(
:final avatarUrl,
:final displayName,
) =>
SegmentedListTile(
onTap: () {},
// context.showUserPopover(
// member.content as MembershipContent,
// member.stateKey!,
// roomId: roomId,
// globalPosition: details.globalPosition,
// ),
title: Text(
displayName ??
member.stateKey!.localpart,
overflow: .ellipsis,
style: .new(
color: member.stateKey!.colorHash,
fontWeight: .bold,
),
),
subtitle: Text(
member.stateKey!,
overflow: .ellipsis,
),
leading: AvatarOrHash(
avatarUrl,
displayName ??
member.sender.localpart,
),
),
_ => throw Exception(
"Member content was not MembershipContent",
),
},
)
.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,13 +92,15 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
), ),
), ),
), ),
leading: isDesktop && room != null leading: isDesktop
? AvatarOrHash( ? room == null
room.metadata?.avatar, ? null
room.metadata?.name ?? "Unnamed Room", : AvatarOrHash(
height: 24, room.metadata?.avatar,
fallback: Icon(Icons.numbers), room.metadata?.name ?? "Unnamed Room",
) height: 24,
fallback: Icon(Icons.numbers),
)
: DrawerButton(onPressed: () => onOpenDrawer(context)), : DrawerButton(onPressed: () => onOpenDrawer(context)),
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
title: room == null title: room == null
@ -123,19 +124,21 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
), ),
], ],
), ),
actions: [ actions: room == null
IconButton( ? .new()
onPressed: null, : .new([
icon: Icon(Icons.push_pin), IconButton(
tooltip: "Open pinned messages", onPressed: null,
), icon: Icon(Icons.push_pin),
IconButton( tooltip: "Open pinned messages",
onPressed: () => onOpenMemberList?.call(context), ),
tooltip: "Open member list", IconButton(
icon: Icon(Icons.people), onPressed: () => onOpenMemberList?.call(context),
), tooltip: "Open member list",
if (room != null) RoomMenu(room), icon: Icon(Icons.people),
].toIList(), ),
RoomMenu(room),
]),
); );
} }
} }

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

@ -1,8 +1,11 @@
import "package:collection/collection.dart"; import "package:collection/collection.dart";
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:navigation_rail_m3e/navigation_rail_m3e.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/models/room.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/join_dialog.dart"; import "package:nexus/widgets/join_dialog.dart";
import "package:nexus/widgets/room_menu.dart"; import "package:nexus/widgets/room_menu.dart";
@ -39,88 +42,136 @@ 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;
List<NavigationRailM3EDestination> roomsToDestinations(IList<Room> rooms) =>
rooms
.map(
(room) => NavigationRailM3EDestination(
label: room.metadata?.name ?? "Unnamed Room",
badgeCount: switch (room.metadata?.unreadNotifications) {
0 || null => room.metadata?.unreadMessages == 0 ? null : 0,
int unread => unread,
},
icon: AvatarOrHash(
room.metadata?.avatar,
room.metadata?.name ?? "Unnamed Room",
fallback: selectedSpaceId == "dms"
? null
: Icon(Icons.numbers),
// space.client.headers,
),
),
)
.toList();
return Drawer( return Drawer(
width: 340,
shape: Border(), shape: Border(),
child: Row( child: Row(
children: [ children: [
NavigationRail( Theme(
scrollable: true, data: Theme.of(context).copyWith(
onDestinationSelected: (value) { extensions: [
selectedSpaceIdNotifier.set(spaces[value].id); NavigationRailM3ETheme(
selectedRoomIdNotifier.set( itemCollapsedHeight: 48,
spaces[value].children.firstOrNull?.metadata?.id, itemVerticalGap: 0,
); ),
}, ],
destinations: spaces ),
.map( child: Container(
(space) => NavigationRailDestination( color: NavigationRailTokensAdapter(context).containerColor,
icon: AvatarOrHash( padding: EdgeInsets.only(top: 16),
space.room?.metadata?.avatar, child: NavigationRailM3E(
fallback: space.icon == null ? null : Icon(space.icon), type: .alwaysCollapse,
space.title, labelBehavior: .alwaysHide,
hasBadge: space.children.any( scrollable: true,
(room) => room.metadata?.unreadMessages != 0, onDestinationSelected: (value) {
), selectedSpaceIdNotifier.set(spaces[value].id);
badgeNumber: space.children.fold( selectedRoomIdNotifier.set(
0, spaces[value].children.firstOrNull?.metadata?.id,
(previousValue, room) => );
previousValue + },
(room.metadata?.unreadNotifications ?? 0), sections: [
), .new(
), destinations: spaces
label: Text(space.title), .map(
padding: .only(top: 4), (space) => NavigationRailM3EDestination(
), badgeCount: switch (space.children.fold(
) 0,
.toList(), (previousValue, room) =>
selectedIndex: selectedIndex, previousValue +
trailingAtBottom: true, (room.metadata?.unreadNotifications ?? 0),
trailing: Padding( )) {
padding: .symmetric(vertical: 16), 0 =>
child: Column( space.children.any(
spacing: 8, (room) =>
children: [ room.metadata?.unreadMessages != 0,
PopupMenuButton( )
itemBuilder: (_) => [ ? 0
PopupMenuItem( : null,
onTap: () => showDialog( int badgeCount => badgeCount,
context: context, },
builder: (_) => JoinDialog(ref), short: true,
), icon: AvatarOrHash(
child: ListTile( space.room?.metadata?.avatar,
title: Text("Join an existing room (or space)"), fallback: space.icon == null
leading: Icon(Icons.numbers), ? null
), : Icon(space.icon),
), space.title,
PopupMenuItem( ),
onTap: null, label: space.title,
child: ListTile( ),
title: Text("Create a new room"), )
leading: Icon(Icons.add), .toList(),
),
),
],
icon: Icon(Icons.add),
),
IconButton(
tooltip: "Explore other rooms",
onPressed: null,
icon: Icon(Icons.explore),
),
IconButton(
tooltip: "Open settings",
onPressed: null,
// () => Navigator.of(
// context,
// ).push(MaterialPageRoute(builder: (_) => SettingsPage())),
icon: Icon(Icons.settings),
), ),
], ],
selectedIndex: selectedIndex,
trailingAtBottom: true,
trailing: Padding(
padding: .symmetric(vertical: 16),
child: Column(
spacing: 8,
children: [
PopupMenuButton(
itemBuilder: (_) => [
PopupMenuItem(
onTap: () => showDialog(
context: context,
builder: (_) => JoinDialog(ref),
),
child: ListTile(
title: Text("Join an existing room (or space)"),
leading: Icon(Icons.numbers),
),
),
PopupMenuItem(
onTap: null,
child: ListTile(
title: Text("Create a new room"),
leading: Icon(Icons.add),
),
),
],
icon: Icon(Icons.add),
),
IconButton(
tooltip: "Explore other rooms",
onPressed: null,
icon: Icon(Icons.explore),
),
IconButton(
tooltip: "Open settings",
onPressed: null,
// () => Navigator.of(
// context,
// ).push(MaterialPageRoute(builder: (_) => SettingsPage())),
icon: Icon(Icons.settings),
),
],
),
),
), ),
), ),
), ),
@ -145,34 +196,40 @@ class Sidebar extends HookConsumerWidget {
), ),
], ],
), ),
body: NavigationRail( body: Theme(
scrollable: true, data: Theme.of(context).copyWith(
backgroundColor: Colors.transparent, extensions: [
extended: true, NavigationRailM3ETheme(
selectedIndex: selectedRoomIndex, itemExpandedHeight: 48,
destinations: selectedSpace.children iconLabelGap: 16,
.map( ),
(room) => NavigationRailDestination( ],
label: Text(room.metadata?.name ?? "Unnamed Room"), ),
icon: AvatarOrHash( child: NavigationRailM3E(
room.metadata?.avatar, expandedWidth: 360,
hasBadge: room.metadata?.unreadMessages != 0, scrollable: true,
badgeNumber: room.metadata?.unreadNotifications ?? 0, background: Colors.transparent,
room.metadata?.name ?? "Unnamed Room", type: .alwaysExpand,
fallback: selectedSpaceId == "dms" selectedIndex: selectedRoomIndex ?? 0,
? null sections: [
: Icon(Icons.numbers), .new(
// space.client.headers, destinations: roomsToDestinations(selectedSpace.children),
),
for (final subSpace in selectedSpace.subSpaces)
.new(
header: Text(
subSpace.room.metadata?.name ?? "Unnamed Room",
), ),
destinations: roomsToDestinations(subSpace.children),
), ),
) ],
.toList(), onDestinationSelected: (value) {
onDestinationSelected: (value) { selectedRoomIdNotifier.set(
selectedRoomIdNotifier.set( selectedSpace.children[value].metadata?.id,
selectedSpace.children[value].metadata?.id, );
); if (!isDesktop) Navigator.of(context).pop();
if (!isDesktop) Navigator.of(context).pop(); },
}, ),
), ),
), ),
), ),

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

@ -6,7 +6,7 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <dynamic_system_colors/dynamic_color_plugin.h> #include <dynamic_color/dynamic_color_plugin.h>
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h> #include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h> #include <media_kit_video/media_kit_video_plugin.h>
@ -15,9 +15,9 @@
#include <window_manager/window_manager_plugin.h> #include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) dynamic_system_colors_registrar = g_autoptr(FlPluginRegistrar) dynamic_color_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin");
dynamic_color_plugin_register_with_registrar(dynamic_system_colors_registrar); dynamic_color_plugin_register_with_registrar(dynamic_color_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar); file_selector_plugin_register_with_registrar(file_selector_linux_registrar);

View file

@ -3,7 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
dynamic_system_colors dynamic_color
file_selector_linux file_selector_linux
media_kit_libs_linux media_kit_libs_linux
media_kit_video media_kit_video

View file

@ -30,9 +30,9 @@ flutter.buildFlutterApplication {
gitHashes = { gitHashes = {
window_size = "sha256-XelNtp7tpZ91QCEcvewVphNUtgQX7xrp5QP0oFo6DgM="; window_size = "sha256-XelNtp7tpZ91QCEcvewVphNUtgQX7xrp5QP0oFo6DgM=";
dynamic_system_colors = "sha256-GInPqU7r4Kj7+CNBQnf95u0BiagOUI6EtcW0A18pfd0=";
emoji_text_field = "sha256-3TOys09EP2GRo6pUBGPXaqBlE39O2Cmwt42Hs1cTDKo="; emoji_text_field = "sha256-3TOys09EP2GRo6pUBGPXaqBlE39O2Cmwt42Hs1cTDKo=";
linkify = "sha256-mxV/XHLxF9cn7sUPr2SUNjVmDr5lbxkuGCbNdyiZi2c="; linkify = "sha256-mxV/XHLxF9cn7sUPr2SUNjVmDr5lbxkuGCbNdyiZi2c=";
navigation_rail_m3e = "sha256-Zkqo8RkNSWiELB+Bk6iLsgft9THuJiMtysEwXNS/ZgY=";
}; };
postInstall = '' postInstall = ''

View file

@ -121,6 +121,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.12.6" version: "8.12.6"
button_m3e:
dependency: transitive
description:
name: button_m3e
sha256: "6754ddeb9068ad2005bd26d5ceabc41268029465095686d7d228296c2e706909"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@ -273,6 +281,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
dynamic_color:
dependency: "direct main"
description:
name: dynamic_color
sha256: "43a5a6679649a7731ab860334a5812f2067c2d9ce6452cf069c5e0c25336c17c"
url: "https://pub.dev"
source: hosted
version: "1.8.1"
dynamic_polls: dynamic_polls:
dependency: "direct main" dependency: "direct main"
description: description:
@ -281,15 +297,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.7" version: "0.0.7"
dynamic_system_colors:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: fc7eea96556aebb1a7cd3272929277bc6d136233
url: "https://github.com/hasali19/flutter_dynamic_system_colors"
source: git
version: "1.9.0"
emoji_text_field: emoji_text_field:
dependency: "direct main" dependency: "direct main"
description: description:
@ -299,6 +306,22 @@ 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"
fab_m3e:
dependency: transitive
description:
name: fab_m3e
sha256: e4f5abfa3c8c092005449d56dcac45b85e2dbe9c32789d672c5ed71428e43b59
url: "https://pub.dev"
source: hosted
version: "0.1.1"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -583,6 +606,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
icon_button_m3e:
dependency: transitive
description:
name: icon_button_m3e
sha256: c4524d6141a468679821bbb635b833ac6831925d8a6ae4a4511430b0e4ab9c67
url: "https://pub.dev"
source: hosted
version: "0.2.1"
idb_shim: idb_shim:
dependency: transitive dependency: transitive
description: description:
@ -744,6 +775,22 @@ 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"
m3e_design:
dependency: transitive
description:
name: m3e_design
sha256: "15ff0ef4c43553d855c5e866a9aee8231d44919fe2bb354b1259337bdfd659b4"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -760,6 +807,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 +903,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:
@ -856,6 +919,15 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.17.6" version: "0.17.6"
navigation_rail_m3e:
dependency: "direct main"
description:
path: "packages/navigation_rail_m3e"
ref: HEAD
resolved-ref: "667b0bc8526fd53296778903b6ef3f22424f3aa4"
url: "https://github.com/Henry-Hiles/material_3_expressive"
source: git
version: "0.3.5"
node_preamble: node_preamble:
dependency: transitive dependency: transitive
description: description:

View file

@ -33,9 +33,7 @@ dependencies:
image_picker: 1.2.2 image_picker: 1.2.2
file_picker: 11.0.2 file_picker: 11.0.2
path: 1.9.1 path: 1.9.1
dynamic_system_colors: dynamic_color: 1.8.1
git:
url: https://github.com/hasali19/flutter_dynamic_system_colors
collection: 1.19.1 collection: 1.19.1
window_manager: 0.5.1 window_manager: 0.5.1
color_hash: 1.0.1 color_hash: 1.0.1
@ -64,6 +62,12 @@ 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
navigation_rail_m3e:
git:
url: https://github.com/Henry-Hiles/material_3_expressive
path: packages/navigation_rail_m3e
dev_dependencies: dev_dependencies:
build_runner: 2.15.0 build_runner: 2.15.0

View file

@ -6,7 +6,7 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <dynamic_system_colors/dynamic_color_plugin_c_api.h> #include <dynamic_color/dynamic_color_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h> #include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h> #include <media_kit_video/media_kit_video_plugin_c_api.h>

View file

@ -3,7 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
dynamic_system_colors dynamic_color
file_selector_windows file_selector_windows
media_kit_libs_windows_video media_kit_libs_windows_video
media_kit_video media_kit_video