add support for creating rooms

Fixes #81
This commit is contained in:
Henry Hiles 2026-10-15 19:49:04 -04:00
commit ed84410039
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
13 changed files with 540 additions and 66 deletions

View file

@ -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<String?> 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<bool>();
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<void> 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",
);
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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<void> showAdaptiveSheet(WidgetBuilder builder) => isDesktop
? showDialog(
Future<T?> showAdaptiveSheet<T>(WidgetBuilder builder) => isDesktop
? showDialog<T>(
context: this,
builder: (context) => Dialog(
clipBehavior: .antiAlias,
@ -15,7 +15,7 @@ extension ShowAdaptiveSheet on BuildContext {
),
),
)
: showModalBottomSheet(
: showModalBottomSheet<T>(
context: this,
isScrollControlled: true,
constraints: .new(maxHeight: MediaQuery.sizeOf(this).height - 80),

View file

@ -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<T?> withProgressSnackbar<T>(
String progress,
Future<T> 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();
}
}
}