diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 3a3e33b..8a3985c 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -20,6 +20,7 @@ import "package:nexus/models/oauth_auth_code_response.dart"; import "package:nexus/models/open_graph_data.dart"; import "package:nexus/models/paginate.dart"; import "package:nexus/models/paginate_manual.dart"; +import "package:nexus/models/requests/create_room.dart"; import "package:nexus/models/requests/deregister_pusher.dart"; import "package:nexus/models/requests/download_media.dart"; import "package:nexus/models/requests/get_event.dart"; @@ -190,6 +191,9 @@ class ClientController extends AsyncNotifier { Future joinRoom(JoinRoomRequest request) async => (await _sendCommand("join_room", request.toJson()))["room_id"]; + Future createRoom(CreateRoomRequest request) async => + (await _sendCommand("create_room", request.toJson()))["room_id"]; + Future getRoomSummary(JoinRoomRequest request) async => .fromJson(await _sendCommand("get_room_summary", request.toJson())); diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart index a732bcd..27c2f91 100644 --- a/lib/controllers/notification.dart +++ b/lib/controllers/notification.dart @@ -36,7 +36,9 @@ class NotificationController if (int.tryParse(details.payload!) case final rowId?) { navigatorKey.currentState?.popUntil((route) => route.isFirst); - ref.read(SidebarPageController.provider.notifier).set(.notifications); + ref + .read(SidebarPageController.provider.notifier) + .set(.notifications); ref .read(NotificationRequestController.provider.notifier) .request(rowId); diff --git a/lib/helpers/extensions/create_room.dart b/lib/helpers/extensions/create_room.dart new file mode 100644 index 0000000..44173ed --- /dev/null +++ b/lib/helpers/extensions/create_room.dart @@ -0,0 +1,111 @@ +import "dart:async"; + +import "package:collection/collection.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/sidebar_page.dart"; +import "package:nexus/controllers/spaces.dart"; +import "package:nexus/helpers/extensions/focus_room.dart"; +import "package:nexus/helpers/extensions/get_all_child_rooms.dart"; +import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; +import "package:nexus/models/requests/create_room.dart"; + +extension CreateRoom on BuildContext { + /// Creates a room, showing progress and errors in snackbars, and opens it + /// once it has synced. [request] may be a future, so any lookups needed to + /// build it happen while the progress snackbar is shown. + /// + /// Returns the created room's ID, or null if creating it failed. + Future createRoom( + CreateRoomRequest request, { + required String name, + }) async { + final container = ProviderScope.containerOf(this); + final messenger = ScaffoldMessenger.of(this); + + final result = await withProgressSnackbar("Creating $name...", () async { + final roomId = await container + .read(ClientController.provider.notifier) + .createRoom(request); + + // Wait for the room to show up in the room list (under DMs, if it's + // one) before opening it. + final synced = Completer(); + final subscription = container.listen( + SpacesController.provider.select( + (async) => + async.value?.any( + (space) => + space.id == roomId || + ((!request.isDirect || space.id == "dms") && + space.allChildRooms.any( + (room) => room.metadata?.id == roomId, + )), + ) ?? + false, + ), + (_, found) { + if (found && !synced.isCompleted) synced.complete(true); + }, + fireImmediately: true, + ); + + return ( + roomId, + await synced.future + .timeout(const .new(seconds: 15), onTimeout: () => false) + .whenComplete(subscription.close), + ); + }); + if (result == null) return null; + + final (roomId, synced) = result; + if (synced && await container.focusRoom(roomId)) { + container.read(SidebarPageController.provider.notifier).set(.rooms); + } else { + messenger.showSnackBar( + .new( + content: Text("Created $name, but it hasn't finished syncing yet."), + ), + ); + } + + return roomId; + } + + /// Opens the existing DM with [userId], or creates one if there isn't one. + Future startDirectMessage( + String userId, { + required String name, + bool encrypted = true, + String? inviteReason, + }) async { + final container = ProviderScope.containerOf(this); + final existing = container + .read(RoomsController.provider) + .values + .firstWhereOrNull((room) => room.metadata?.dmUserId == userId) + ?.metadata + ?.id; + + if (existing != null) { + if (await container.focusRoom(existing)) { + container.read(SidebarPageController.provider.notifier).set(.rooms); + } + return; + } + + await createRoom( + .new( + isDirect: true, + preset: .trustedPrivateChat, + invite: .new([userId]), + inviteReason: inviteReason, + initialState: encrypted ? .new([.encryption]) : const .empty(), + ), + name: "a DM with $name", + ); + } +} diff --git a/lib/helpers/extensions/get_localpart.dart b/lib/helpers/extensions/get_localpart.dart index fa3d285..729d921 100644 --- a/lib/helpers/extensions/get_localpart.dart +++ b/lib/helpers/extensions/get_localpart.dart @@ -1,3 +1,8 @@ extension GetLocalpart on String { String get localpart => length > 1 ? substring(1).split(":").first : "?"; + + String? get serverName { + final index = indexOf(":"); + return index == -1 ? null : substring(index + 1); + } } diff --git a/lib/helpers/extensions/join_room.dart b/lib/helpers/extensions/join_room.dart index 90cdd34..be5d6a5 100644 --- a/lib/helpers/extensions/join_room.dart +++ b/lib/helpers/extensions/join_room.dart @@ -3,6 +3,7 @@ import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/sidebar_page.dart"; import "package:nexus/helpers/extensions/focus_room.dart"; +import "package:nexus/helpers/extensions/with_progress_snackbar.dart"; import "package:nexus/models/requests/join_room.dart"; extension JoinRoom on BuildContext { @@ -16,56 +17,27 @@ extension JoinRoom on BuildContext { }) async { final container = ProviderScope.containerOf(this); final messenger = ScaffoldMessenger.of(this); - final colorScheme = Theme.of(this).colorScheme; - final joining = messenger.showSnackBar( - .new(content: Text("Joining $name..."), duration: const .new(days: 1)), + final roomId = await withProgressSnackbar( + "Joining $name...", + () => + container.read(ClientController.provider.notifier).joinRoom(request), + ); + if (roomId == null) return null; + + messenger.showSnackBar( + .new( + content: Text("Joined $name."), + action: .new( + label: "Open", + onPressed: () async { + await container.focusRoom(roomId); + container.read(SidebarPageController.provider.notifier).set(.rooms); + }, + ), + ), ); - try { - final roomId = await container - .read(ClientController.provider.notifier) - .joinRoom(request); - - joining.close(); - messenger.showSnackBar( - .new( - content: Text("Joined $name."), - action: .new( - label: "Open", - onPressed: () async { - if (await container.focusRoom(roomId)) { - container - .read(SidebarPageController.provider.notifier) - .set(.rooms); - } else { - messenger.showSnackBar( - .new( - content: Text( - "$name hasn't finished syncing yet, try again in a moment.", - ), - ), - ); - } - }, - ), - ), - ); - - return roomId; - } catch (error) { - joining.close(); - messenger.showSnackBar( - .new( - backgroundColor: colorScheme.errorContainer, - content: Text( - error.toString(), - style: .new(color: colorScheme.onErrorContainer), - ), - ), - ); - - return null; - } + return roomId; } } diff --git a/lib/helpers/extensions/show_adaptive_sheet.dart b/lib/helpers/extensions/show_adaptive_sheet.dart index 475289e..6b4e341 100644 --- a/lib/helpers/extensions/show_adaptive_sheet.dart +++ b/lib/helpers/extensions/show_adaptive_sheet.dart @@ -4,8 +4,8 @@ import "package:nexus/helpers/extensions/is_desktop.dart"; extension ShowAdaptiveSheet on BuildContext { /// Shows the widget from [builder] in a dialog on desktop, or a bottom sheet /// on mobile. Use the context passed to [builder] to close it. - Future showAdaptiveSheet(WidgetBuilder builder) => isDesktop - ? showDialog( + Future showAdaptiveSheet(WidgetBuilder builder) => isDesktop + ? showDialog( context: this, builder: (context) => Dialog( clipBehavior: .antiAlias, @@ -15,7 +15,7 @@ extension ShowAdaptiveSheet on BuildContext { ), ), ) - : showModalBottomSheet( + : showModalBottomSheet( context: this, isScrollControlled: true, constraints: .new(maxHeight: MediaQuery.sizeOf(this).height - 80), diff --git a/lib/helpers/extensions/with_progress_snackbar.dart b/lib/helpers/extensions/with_progress_snackbar.dart new file mode 100644 index 0000000..cd8e494 --- /dev/null +++ b/lib/helpers/extensions/with_progress_snackbar.dart @@ -0,0 +1,37 @@ +import "package:material_ui/material_ui.dart"; + +extension WithProgressSnackbar on BuildContext { + /// Shows [progress] in a snackbar while [action] runs, and shows any error + /// it throws in an error snackbar. + /// + /// Returns the result of [action], or null if it failed. + Future withProgressSnackbar( + String progress, + Future Function() action, + ) async { + final messenger = ScaffoldMessenger.of(this); + final colorScheme = Theme.of(this).colorScheme; + + final snackbar = messenger.showSnackBar( + .new(content: Text(progress), duration: const .new(days: 1)), + ); + + try { + return await action(); + } catch (error) { + messenger.showSnackBar( + .new( + backgroundColor: colorScheme.errorContainer, + content: Text( + error.toString(), + style: .new(color: colorScheme.onErrorContainer), + ), + ), + ); + + return null; + } finally { + snackbar.close(); + } + } +} diff --git a/lib/models/requests/create_room.dart b/lib/models/requests/create_room.dart new file mode 100644 index 0000000..d2fbcc9 --- /dev/null +++ b/lib/models/requests/create_room.dart @@ -0,0 +1,52 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/encryption.dart"; + +part "create_room.freezed.dart"; +part "create_room.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const CreateRoomRequest({ + final RoomVisibility? visibility, + final String? roomAliasName, + final String? name, + final String? topic, + final IList invite = const IList.empty(), + @JsonKey(name: "uk.timedout.msc4491.invite_reason") + final String? inviteReason, + final IList initialState = const IList.empty(), + final RoomPreset? preset, + final bool isDirect = false, + final IMap? creationContent, +}) with _$CreateRoomRequest { + Map toJson() => _$CreateRoomRequestToJson(this); + + factory CreateRoomRequest.fromJson(Map json) => + _$CreateRoomRequestFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const InitialStateEvent({ + required final String type, + final String stateKey = "", + required final Content content, +}) with _$InitialStateEvent { + static final encryption = InitialStateEvent( + type: EventType.encryption.type, + content: EncryptionContent(algorithm: "m.megolm.v1.aes-sha2"), + ); + + Map toJson() => _$InitialStateEventToJson(this); + + factory InitialStateEvent.fromJson(Map json) => + _$InitialStateEventFromJson(json); +} + +@JsonEnum(fieldRename: .snake) +enum RoomVisibility { public, private } + +@JsonEnum(fieldRename: .snake) +enum RoomPreset { privateChat, publicChat, trustedPrivateChat } diff --git a/lib/widgets/create_room_sheet.dart b/lib/widgets/create_room_sheet.dart new file mode 100644 index 0000000..423b909 --- /dev/null +++ b/lib/widgets/create_room_sheet.dart @@ -0,0 +1,149 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/helpers/extensions/create_room.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +class const CreateRoomSheet({super.key}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final serverName = ref + .watch(ClientStateController.provider) + ?.userId + ?.serverName; + final nameController = useTextEditingController(); + final topicController = useTextEditingController(); + final aliasController = useTextEditingController(); + final name = useValueListenable(nameController).text.trim(); + final isPublic = useState(false); + final isPublished = useState(false); + final isEncrypted = useState(false); + final isSpace = useState(false); + + void submit() { + if (name.isEmpty) return; + + final topic = topicController.text.trim(); + final alias = aliasController.text.trim(); + + context.createRoom( + .new( + name: name, + topic: topic.isEmpty ? null : topic, + roomAliasName: isPublic.value && alias.isNotEmpty ? alias : null, + visibility: isPublic.value && isPublished.value ? .public : .private, + preset: isPublic.value ? .publicChat : .privateChat, + initialState: isEncrypted.value && !isSpace.value + ? .new([.encryption]) + : const .empty(), + creationContent: isSpace.value ? .new({"type": "m.space"}) : null, + ), + name: name, + ); + Navigator.of(context).pop(); + } + + AnimatedSize animated({ + required bool visible, + required Widget child, + double gap = 12, + }) => AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: visible + ? Padding( + padding: .only(top: gap), + child: child, + ) + : const SizedBox(width: .infinity), + ); + + return SheetLayout( + title: Text("Create a ${isSpace.value ? "Space" : "Room"}"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: name.isEmpty ? null : submit, + child: const Text("Create"), + ), + ], + children: [ + TextField( + controller: nameController, + autofocus: true, + textCapitalization: .sentences, + onSubmitted: (_) => submit(), + decoration: const .new(labelText: "Name"), + ), + TextField( + controller: topicController, + textCapitalization: .sentences, + decoration: const .new(labelText: "Topic (optional)"), + ), + Column( + children: [ + SwitchListTile( + secondary: const Icon(Icons.public), + title: const Text("Public"), + subtitle: const Text("Anyone can join"), + value: isPublic.value, + onChanged: (value) { + isPublic.value = value; + if (value) isEncrypted.value = false; + }, + ), + animated( + visible: isPublic.value, + child: TextField( + controller: aliasController, + decoration: .new( + labelText: "Canonical alias (optional)", + prefixText: "# ", + suffixText: serverName == null ? null : ":$serverName", + hintText: "my-room", + ), + ), + ), + animated( + visible: isPublic.value, + child: SwitchListTile( + secondary: const Icon(Icons.travel_explore), + title: const Text("Publish to room directory"), + subtitle: const Text("List it in your homeserver's directory"), + value: isPublished.value, + onChanged: (value) => isPublished.value = value, + ), + ), + ], + ), + Column( + children: [ + SwitchListTile( + secondary: const Icon(Icons.workspaces), + title: const Text("Space"), + subtitle: const Text("A group of rooms"), + value: isSpace.value, + onChanged: (value) => isSpace.value = value, + ), + animated( + visible: !isSpace.value, + child: SwitchListTile( + secondary: const Icon(Icons.lock), + title: const Text("Encrypted"), + subtitle: const Text("Can't be turned off later"), + value: isEncrypted.value, + onChanged: (value) => isEncrypted.value = value, + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/room_list.dart b/lib/widgets/room_list.dart index 150db04..3d75339 100644 --- a/lib/widgets/room_list.dart +++ b/lib/widgets/room_list.dart @@ -5,9 +5,12 @@ import "package:material_ui/material_ui.dart"; import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; import "package:nexus/helpers/extensions/get_all_child_rooms.dart"; import "package:nexus/helpers/extensions/room_destinations.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/widgets/adaptive_menu_button.dart"; import "package:nexus/models/nav_page.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/create_room_sheet.dart"; +import "package:nexus/widgets/start_dm_sheet.dart"; import "package:nexus/widgets/divider_widget.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/pages/settings.dart"; @@ -163,13 +166,24 @@ class const SpacesRail({super.key}) extends ConsumerWidget { leading: Icon(Icons.numbers), ), ), - const PopupMenuItem( - onTap: null, - child: ListTile( - title: Text("Create a new room"), + PopupMenuItem( + onTap: () => context.showAdaptiveSheet( + (_) => const CreateRoomSheet(), + ), + child: const ListTile( + title: Text("Create a new room (or space)"), leading: Icon(Icons.add), ), ), + PopupMenuItem( + onTap: () => context.showAdaptiveSheet( + (_) => const StartDmSheet(), + ), + child: const ListTile( + title: Text("Start a direct message"), + leading: Icon(Icons.person_add), + ), + ), ], icon: const Icon(Icons.add), ), diff --git a/lib/widgets/sheet_layout.dart b/lib/widgets/sheet_layout.dart new file mode 100644 index 0000000..40f9e15 --- /dev/null +++ b/lib/widgets/sheet_layout.dart @@ -0,0 +1,29 @@ +import "package:material_ui/material_ui.dart"; + +class const SheetLayout({ + required final Widget title, + required final List children, + required final List actions, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) => Padding( + padding: .only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: SingleChildScrollView( + padding: const .all(24), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + spacing: 12, + children: [ + DefaultTextStyle( + style: Theme.of(context).textTheme.headlineSmall!, + child: title, + ), + ...children, + Row(mainAxisAlignment: .end, spacing: 8, children: actions), + ], + ), + ), + ); +} diff --git a/lib/widgets/start_dm_sheet.dart b/lib/widgets/start_dm_sheet.dart new file mode 100644 index 0000000..98bf042 --- /dev/null +++ b/lib/widgets/start_dm_sheet.dart @@ -0,0 +1,77 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/create_room.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Starts a DM, asking for a user ID unless [userId] is given. Pops with true +/// if a DM was started. +class const StartDmSheet({final String? userId, final String? name, super.key}) + extends HookWidget { + @override + Widget build(BuildContext context) { + final userIdController = useTextEditingController(); + final text = useValueListenable(userIdController).text.trim(); + final target = userId ?? text; + final valid = RegExp(r"^@[^:\s]+:[^\s]+$").hasMatch(target); + final encrypted = useState(true); + final reasonController = useTextEditingController(); + + void submit() { + if (!valid) return; + context.startDirectMessage( + target, + name: name ?? target, + encrypted: encrypted.value, + inviteReason: reasonController.text.trim().isEmpty + ? null + : reasonController.text.trim(), + ); + Navigator.of(context).pop(true); + } + + return SheetLayout( + title: const Text("Start a Direct Message"), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: valid ? submit : null, + child: const Text("Start"), + ), + ], + children: [ + if (userId == null) + TextField( + controller: userIdController, + autofocus: true, + keyboardType: .emailAddress, + onSubmitted: (_) => submit(), + decoration: .new( + labelText: "User ID", + hintText: "@user:example.com", + errorText: text.isEmpty || valid ? null : "Enter a valid user ID", + ), + ) + else + Text( + "Are you sure you want to start a direct message with ${name ?? userId}?", + ), + TextField( + controller: reasonController, + textCapitalization: .sentences, + onSubmitted: (_) => submit(), + decoration: const .new(labelText: "Invite reason (optional)"), + ), + SwitchListTile( + secondary: const Icon(Icons.lock), + title: const Text("Encrypted"), + subtitle: const Text("Can't be turned off later"), + value: encrypted.value, + onChanged: (value) => encrypted.value = value, + ), + ], + ); + } +} diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index b6c35ae..4e654a3 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -8,13 +8,17 @@ import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/power_level.dart"; import "package:nexus/controllers/profile.dart"; +import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/create_room.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_action.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/start_dm_sheet.dart"; final class const UserBottomSheet( final MembershipContent member, @@ -177,13 +181,30 @@ final class const UserBottomSheet( ), SizedBox(height: 8), - if (userId != ref.watch(ClientStateController.provider)?.userId && - roomId != null) ...[ + if (userId != + ref.watch(ClientStateController.provider)?.userId) ...[ Row( children: [ Expanded( child: M3EButton.icon( - onPressed: null, + onPressed: () async { + final name = member.displayName ?? userId.localpart; + final hasDm = ref + .read(RoomsController.provider) + .values + .any((room) => room.metadata?.dmUserId == userId); + + if (hasDm) { + context.startDirectMessage(userId, name: name); + } else if (await context.showAdaptiveSheet( + (_) => StartDmSheet(userId: userId, name: name), + ) != + true) { + return; + } + + if (context.mounted) Navigator.of(context).pop(); + }, shape: .square, style: .tonal, icon: Icon(Icons.message), @@ -193,15 +214,16 @@ final class const UserBottomSheet( ], ), - if (ref.watch( - PowerLevelController.provider( - .membershipAction( - action: .kick, - roomId: roomId!, - targetUser: userId, - ), - ), - )) + if (roomId != null && + ref.watch( + PowerLevelController.provider( + .membershipAction( + action: .kick, + roomId: roomId!, + targetUser: userId, + ), + ), + )) Padding( padding: .only(top: 4), child: Row(