add invite support

Closes #82
This commit is contained in:
Henry Hiles 2026-10-15 00:35:38 -04:00
commit d37ec2878b
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
21 changed files with 561 additions and 147 deletions

View file

@ -1,9 +1,17 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
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/invites.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/main.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/nav_page.dart";
import "package:nexus/models/room_summary.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/room_summary_dialog.dart";
class const InvitesList({super.key})
extends StatelessWidget
extends HookConsumerWidget
implements NavPage {
@override
String get title => "Invites";
@ -11,12 +19,117 @@ class const InvitesList({super.key})
@override
IconData get icon => Icons.inbox;
// TODO: Show notification badge here
@override
Future<int>? badgeBuilder(WidgetRef ref) => null;
int? badgeCount(WidgetRef ref) => switch (ref.watch(
InvitesController.provider.select((invites) => invites.length),
)) {
0 => null,
final count => count,
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final invites = ref.watch(InvitesController.provider);
final decliningAll = useState(false);
Future<void> declineAll() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("Decline All Invites"),
content: Text(
"Are you sure you want to decline all ${invites.length} invites?",
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text("Cancel"),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text("Decline All"),
),
],
),
);
if (confirmed != true) return;
decliningAll.value = true;
await ref
.read(InvitesController.provider.notifier)
.declineAll()
.onError(showError);
if (context.mounted) decliningAll.value = false;
}
return Scaffold(
backgroundColor: Colors.transparent,
floatingActionButton: invites.isEmpty
? null
: FloatingActionButton.extended(
onPressed: decliningAll.value ? null : declineAll,
icon: decliningAll.value
? const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(),
)
: const Icon(Icons.clear_all),
label: const Text("Decline all"),
),
body: invites.isEmpty
? Center(
child: Text(
"No invites",
style: Theme.of(context).textTheme.headlineMedium,
),
)
: ListView(
// Leave room for the FAB under the last invite
padding: const .only(left: 8, top: 8, right: 8, bottom: 88),
children: invites.map(InviteCard.new).toList(),
),
);
}
}
class const InviteCard(final RoomSummary invite, {super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const Placeholder();
final name = invite.name ?? invite.canonicalAlias ?? invite.roomId;
final inviterName =
invite.inviterProfile?.displayName ?? invite.invitedBy?.localpart;
return Card(
clipBehavior: .antiAlias,
child: ListTile(
onTap: () => showDialog(
context: context,
builder: (_) => RoomSummaryDialog(invite),
),
leading: AvatarOrHash(
invite.avatarUrl,
name,
dimension: 40,
fallback: invite.isDirect
? null
: Icon(
invite.roomType == RoomType.space
? Icons.workspaces
: Icons.numbers,
),
),
title: Text(name, maxLines: 1, overflow: .ellipsis),
subtitle: Text(
[
if (inviterName != null)
"${invite.isDirect ? "Direct message from" : "Invited by"} $inviterName",
?invite.topic,
].join("\n"),
maxLines: 2,
overflow: .ellipsis,
),
),
);
}
}

View file

@ -24,9 +24,8 @@ class const NotificationsList({super.key})
@override
IconData get icon => Icons.notifications;
// TODO: Show notification badge here
@override
Future<int>? badgeBuilder(WidgetRef ref) => null;
Null badgeCount(_) => null;
@override
Widget build(BuildContext context, WidgetRef ref) {

View file

@ -28,7 +28,7 @@ class const RoomList({super.key}) extends ConsumerWidget implements NavPage {
// TODO: Show unread badge here and on sidebar hamburger
@override
Future<int>? badgeBuilder(WidgetRef ref) => null;
int? badgeCount(WidgetRef ref) => null;
@override
Widget build(BuildContext context, WidgetRef ref) {

View file

@ -127,7 +127,7 @@ final class const RoomMenuButton(
),
);
await client
.leaveRoom(room!)
.leaveRoom(room!.metadata!.id)
.onError(showError)
.whenComplete(snackbar.close);
},

View file

@ -1,8 +1,10 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/helpers/extensions/focus_room.dart";
import "package:nexus/controllers/invites.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/join_room.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/linkified_text.dart";
@ -14,109 +16,129 @@ class const RoomSummaryDialog(
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => AlertDialog(
constraints: .loose(.fromWidth(400)),
scrollable: true,
contentPadding: EdgeInsets.all(24).copyWith(top: 8),
title: Row(
spacing: 12,
mainAxisSize: .min,
children: [
if (summary.avatarUrl != null)
ExpandableImage(
summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!),
child: AvatarOrHash(
summary.avatarUrl,
"",
dimension: 64,
fallback: Icon(Icons.numbers),
),
),
Expanded(
child: Text(
summary.name ?? summary.canonicalAlias ?? "Unnamed Room",
overflow: .ellipsis,
maxLines: 3,
),
),
],
),
content: Column(
children: [
ListTile(
title: Text(
"${summary.joinedMembers ?? "Unknown number of"} members",
),
leading: Icon(Icons.people),
),
ListTile(
title: SelectableText(summary.canonicalAlias ?? summary.roomId),
leading: Icon(Icons.numbers),
),
Widget build(BuildContext context) {
final name = summary.name ?? summary.canonicalAlias ?? summary.roomId;
final isInvite = summary.membership == .invite;
final colorScheme = Theme.of(context).colorScheme;
if (summary.topic != null)
ListTile(
titleAlignment: ListTileTitleAlignment.titleHeight,
title: LinkifiedText(
summary.topic!,
style: Theme.of(context).textTheme.bodyLarge,
),
leading: Icon(Icons.info),
),
],
),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
if (via != null)
TextButton(
onPressed: () async {
// Capture everything before the dialog closes.
final container = ProviderScope.containerOf(context);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final colors = Theme.of(context).colorScheme;
Navigator.of(context).pop();
final roomIdOrAlias = summary.canonicalAlias ?? summary.roomId;
final snackbar = scaffoldMessenger.showSnackBar(
.new(
content: Text("Joining room $roomIdOrAlias."),
duration: Duration(days: 999),
return AlertDialog(
constraints: .loose(.fromWidth(400)),
scrollable: true,
contentPadding: EdgeInsets.all(24).copyWith(top: 8),
title: Row(
spacing: 12,
mainAxisSize: .min,
children: [
if (summary.avatarUrl != null)
ExpandableImage(
summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!),
child: AvatarOrHash(
summary.avatarUrl,
"",
dimension: 64,
fallback: Icon(Icons.numbers),
),
);
),
Expanded(
child: Text(
summary.name ?? summary.canonicalAlias ?? "Unnamed Room",
overflow: .ellipsis,
maxLines: 3,
),
),
],
),
content: Column(
children: [
if (summary.invitedBy case final invitedBy?)
ListTile(
leading: AvatarOrHash(
summary.inviterProfile?.avatarUrl,
summary.inviterProfile?.displayName ?? invitedBy.localpart,
),
title: Text(
"${summary.isDirect ? "Direct message from" : "Invited by"} ${summary.inviterProfile?.displayName ?? invitedBy.localpart}",
),
subtitle: SelectableText(invitedBy),
),
// Invite state never includes the member count
if (summary.joinedMembers != null || !isInvite)
ListTile(
title: Text(
"${summary.joinedMembers ?? "Unknown number of"} members",
),
leading: Icon(Icons.people),
),
ListTile(
title: SelectableText(summary.canonicalAlias ?? summary.roomId),
leading: Icon(Icons.numbers),
),
if (summary.topic != null)
ListTile(
titleAlignment: ListTileTitleAlignment.titleHeight,
title: LinkifiedText(
summary.topic!,
style: Theme.of(context).textTheme.bodyLarge,
),
leading: Icon(Icons.info),
),
if (summary.encryption != null)
ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)),
if (summary.worldReadable == true)
ListTile(
title: Text("Anyone can read the history"),
leading: Icon(Icons.public),
),
],
),
actions: [
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
if (isInvite) ...[
TextButton(
onPressed: () {
final container = ProviderScope.containerOf(context);
Navigator.of(context).pop();
try {
final id = await container
.read(ClientController.provider.notifier)
.joinRoom(.new(roomIdOrAlias: summary.roomId, via: via!));
container
.read(InvitesController.provider.notifier)
.decline(summary.roomId)
.onError(showError);
},
style: TextButton.styleFrom(foregroundColor: colorScheme.error),
child: Text("Decline"),
),
TextButton(
onPressed: () {
final container = ProviderScope.containerOf(context);
snackbar.close();
scaffoldMessenger.showSnackBar(
.new(
content: Text("Room $roomIdOrAlias successfully joined."),
action: .new(
label: "Open",
onPressed: () => container.focusRoom(id),
),
),
context
.joinRoom(
.new(roomIdOrAlias: summary.roomId, fromInvite: true),
name: name,
)
.then((roomId) {
if (roomId != null) {
container
.read(InvitesController.provider.notifier)
.remove(summary.roomId);
}
});
Navigator.of(context).pop();
},
child: Text("Accept"),
),
] else if (via != null)
TextButton(
onPressed: () {
context.joinRoom(
.new(roomIdOrAlias: summary.roomId, via: via!),
name: name,
);
} catch (error) {
snackbar.close();
scaffoldMessenger.showSnackBar(
.new(
backgroundColor: colors.errorContainer,
content: Text(
error.toString(),
style: .new(color: colors.onErrorContainer),
),
),
);
}
},
child: Text("Join"),
),
],
);
Navigator.of(context).pop();
},
child: Text("Join"),
),
],
);
}
}

View file

@ -1,47 +1,47 @@
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/notification_request.dart";
import "package:nexus/controllers/sidebar_page.dart";
import "package:nexus/models/nav_page.dart";
import "package:nexus/models/sidebar_page.dart";
import "package:nexus/widgets/invites_list.dart";
import "package:nexus/widgets/notifications_list.dart";
import "package:nexus/widgets/room_list.dart";
class const Sidebar({super.key}) extends HookConsumerWidget {
class const Sidebar({super.key}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
const pages = <NavPage>[RoomList(), NotificationsList(), InvitesList()];
final notificationsIndex = pages.indexWhere(
(page) => page is NotificationsList,
final selected = ref.watch(SidebarPageController.provider);
final pages = SidebarPage.values.map<NavPage>(
(page) => switch (page) {
.rooms => const RoomList(),
.notifications => const NotificationsList(),
.invites => const InvitesList(),
},
);
final selectedIndex = useState(
ref.read(NotificationRequestController.provider) == null
? 0
: notificationsIndex,
);
ref.listen(NotificationRequestController.provider, (_, next) {
if (next != null) selectedIndex.value = notificationsIndex;
});
return Drawer(
width: 330,
shape: Border(),
child: Scaffold(
backgroundColor: Colors.transparent,
body: pages[selectedIndex.value],
body: pages.elementAt(selected.index),
bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex.value,
destinations: pages
.map(
(element) => NavigationDestination(
icon: Icon(element.icon),
label: element.title,
),
)
.toList(),
onDestinationSelected: (value) => selectedIndex.value = value,
selectedIndex: selected.index,
destinations: pages.map((page) {
final badgeCount = page.badgeCount(ref);
return NavigationDestination(
icon: Badge.count(
count: badgeCount ?? 0,
isLabelVisible: badgeCount != null,
child: Icon(page.icon),
),
label: page.title,
);
}).toList(),
onDestinationSelected: (index) => ref
.read(SidebarPageController.provider.notifier)
.set(SidebarPage.values[index]),
),
),
);